Skip to content

Instantly share code, notes, and snippets.

@iantanwx
Created July 24, 2017 08:36
Show Gist options
  • Save iantanwx/74914e9d4b4eaa82c279f938a229e3ad to your computer and use it in GitHub Desktop.
Save iantanwx/74914e9d4b4eaa82c279f938a229e3ad to your computer and use it in GitHub Desktop.
Variance Quirks in TypeScript
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