Created
July 24, 2017 08:36
-
-
Save iantanwx/74914e9d4b4eaa82c279f938a229e3ad to your computer and use it in GitHub Desktop.
Variance Quirks in TypeScript
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
interface Animal { | |
play(arg: Toy): Toy; | |
} | |
interface Dog extends Animal { | |
type: 'dog'; | |
} | |
interface Cat extends Animal { | |
type: 'cat'; | |
} | |
interface PetShop<T> { | |
[name: string]: T; | |
} | |
interface Toy {} | |
interface Ball extends Toy { | |
type: 'ball'; | |
} | |
interface Yarn extends Toy { | |
type: 'yarn'; | |
} | |
// The compiler complains because we are adding | |
// the property 'type', which does not exist on the interface Animal. | |
const dogPetShop: PetShop<Animal> = { | |
buster: { | |
type: 'dog', | |
play(toy: Ball) { | |
return toy; | |
}, | |
}, | |
}; | |
// Prelude: https://medium.com/@thejameskyle/type-systems-covariance-contravariance-bivariance-and-invariance-explained-35f43d1110f8 | |
// Read that for tldr explanation of variance if not familiar with C#/Java/Scala etc | |
// Notice that the compiler doesn't complain here | |
// This makes it seems like the interface PetShop<T> is covariant | |
// In the type parameter T. It isn't really invariant. We passed in Animal, | |
// But gave it a Dog. It type checks becuase Dog extends Animal, and | |
// therefore has all the properties of Animal (and and additional property, 'type'). We can only do this by passing | |
// a reference to a subtype (petDog), but it won't work if we directly use | |
// an object literal as in the above example, even though the objects would | |
// pass a shallow equality test. | |
const petDog: Dog = { | |
type: 'dog', | |
play(toy: Ball) { | |
return toy; | |
}, | |
}; | |
const covariantDogPetShop: PetShop<Animal> = { | |
buster: petDog, | |
}; | |
// now the interesting part. | |
interface Bone extends Toy { | |
type: 'bone'; | |
} | |
// DogToys is a tagged union aka discriminated type. | |
type DogToys = Ball | Bone; | |
// This is intuitive and typechecks; we might want to take some action | |
// on the toy passed in, and just give back another toy. The compiler allows | |
// us to declare this type of function signature. | |
interface DogWithManyToys extends Dog { | |
play(toy: DogToys): DogToys; | |
} | |
// We know for a FACT that chewedToy conforms to | |
// one of the DogToys interfaces. | |
// But the compiler won't allow us to do this. Why? | |
// Becuase the type of the variable type is in | |
// fact the union type 'ball' | 'bone'. | |
// The compiler then attempts to match this against the 'ball' | 'bone' | |
// against each of the string literal types in 'ball' | 'bone', which | |
// will NEVER match. | |
const playfulDog: DogWithManyToys = { | |
type: 'dog', | |
play(toy: DogToys) { | |
const { type } = toy; | |
const chewedToy = { ...toy, type, chewed: true }; | |
return chewedToy; | |
}, | |
}; | |
// This typechecks. We are returning the exact same object as above. | |
// The compiler doesn't complain because it knows that toy.type is EITHER | |
// the string literal 'ball' or 'bone', but once it is destructured, the | |
// type of toy.type is no longer a string literal but a UNION TYPE. That | |
// is why TypeScript compiler complains. | |
// This in fact a reported bug: | |
// https://github.com/Microsoft/TypeScript/issues/16144 but it has | |
// not yet been fixed. So: always use a type guard and never destructure if | |
// you are returning a union type!!! | |
const discriminatedPlayfulDog: DogWithManyToys = { | |
type: 'dog', | |
play(toy: DogToys) { | |
if (toy.type === 'bone') { | |
return { ...toy, chewed: true }; | |
} | |
if (toy.type === 'ball') { | |
return { ...toy, rolled: true }; | |
} | |
}, | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment