Type Compatibility

Type Compatibility (as we discuss here) determines if one thing can be assigned to another. E.g. string and number are not compatible:

  1. let str: string = "Hello";
  2. let num: number = 123;
  3. str = num; // ERROR: `number` is not assignable to `string`
  4. num = str; // ERROR: `string` is not assignable to `number`

Soundness

TypeScript’s type system is designed to be convenient and allows for unsound behaviours e.g. anything can be assigned to any which essentially means you telling the compiler to allow you to do whatever you want:

  1. let foo: any = 123;
  2. foo = "Hello";
  3. // Later
  4. foo.toPrecision(3); // Allowed as you typed it as `any`

Structural

TypeScript objects are structurally typed. This means the names don’t matter as long as the structures match

  1. interface Point {
  2. x: number,
  3. y: number
  4. }
  5. class Point2D {
  6. constructor(public x:number, public y:number){}
  7. }
  8. let p: Point;
  9. // OK, because of structural typing
  10. p = new Point2D(1,2);

This allows you to create objects on the fly (like you do in vanilla JS) and still have safety for whenever it can be inferred.

Also more data is considered fine:

  1. interface Point2D {
  2. x: number;
  3. y: number;
  4. }
  5. interface Point3D {
  6. x: number;
  7. y: number;
  8. z: number;
  9. }
  10. var point2D: Point2D = { x: 0, y: 10 }
  11. var point3D: Point3D = { x: 0, y: 10, z: 20 }
  12. function iTakePoint2D(point: Point2D) { /* do something */ }
  13. iTakePoint2D(point2D); // exact match okay
  14. iTakePoint2D(point3D); // extra information okay
  15. iTakePoint2D({ x: 0 }); // Error: missing information `y`

Variance

Variance is an easy to understand and important concept for type compatibility analysis.

For simple types Base and Child, if Child is a child of Base, then instances of Child can be assigned to a variable to type Base.

This is polymorphism 101

In type compatibility of complex types composed of such Base and Child depending on where the Base and Child in similar scenarios is driven by variance.

  • Covariant : (corporate) only in same direction
  • Contravariant : (contra aka negative) only in opposite direction
  • Bivariant : (bi aka both) both co and contra.
  • Invariant : if the types are aren’t exact then they are incompatible.

Note: For a completely sound type system in the presence of mutable data like JavaScript, invariant is the only valid option. But as mentioned convenience forces us to make unsound choices.

Functions

There are a few subtle things to consider when comparing two functions.

Return Type

covariant: The return type must contain at least enough data.

  1. /** Type Heirarchy */
  2. interface Point2D { x: number; y: number; }
  3. interface Point3D { x: number; y: number; z: number; }
  4. /** Two sample functions */
  5. let iMakePoint2D = (): Point2D => ({ x: 0, y: 0 });
  6. let iMakePoint3D = (): Point3D => ({ x: 0, y: 0, z: 0 });
  7. /** Assignment */
  8. iMakePoint2D = iMakePoint3D; // Okay
  9. iMakePoint3D = iMakePoint2D; // ERROR: Point2D is not assignable to Point3D

Number of arguments

Less arguments are okay (i.e. functions can chose to ignore additional args). After all you are guaranteed to be called with at least enough arguments.

  1. let iTakeSomethingAndPassItAnErr
  2. = (x: (err: Error, data: any) => void) => { /* do something */ };
  3. iTakeSomethingAndPassItAnErr(() => null) // Okay
  4. iTakeSomethingAndPassItAnErr((err) => null) // Okay
  5. iTakeSomethingAndPassItAnErr((err, data) => null) // Okay
  6. // ERROR: function may be called with `more` not being passed in
  7. iTakeSomethingAndPassItAnErr((err, data, more) => null); // ERROR

Optional and Rest Parameters

Optional (pre determined count) and Rest parameters (any count of arguments) are compatible, again for convenience.

  1. let foo = (x:number, y: number) => { /* do something */ }
  2. let bar = (x?:number, y?: number) => { /* do something */ }
  3. let bas = (...args: number[]) => { /* do something */ }
  4. foo = bar = bas;
  5. bas = bar = foo;

Note: optional (in our example bar) and non optional (in our example foo) are only compatible if strictNullChecks is false.

Types of arguments

bivariant : This is designed to support common event handling scenarios

  1. /** Event Hierarchy */
  2. interface Event { timestamp: number; }
  3. interface MouseEvent extends Event { x: number; y: number }
  4. interface KeyEvent extends Event { keyCode: number }
  5. /** Sample event listener */
  6. enum EventType { Mouse, Keyboard }
  7. function addEventListener(eventType: EventType, handler: (n: Event) => void) {
  8. /* ... */
  9. }
  10. // Unsound, but useful and common. Works as function argument comparison is bivariant
  11. addEventListener(EventType.Mouse, (e: MouseEvent) => console.log(e.x + "," + e.y));
  12. // Undesirable alternatives in presence of soundness
  13. addEventListener(EventType.Mouse, (e: Event) => console.log((<MouseEvent>e).x + "," + (<MouseEvent>e).y));
  14. addEventListener(EventType.Mouse, <(e: Event) => void>((e: MouseEvent) => console.log(e.x + "," + e.y)));
  15. // Still disallowed (clear error). Type safety enforced for wholly incompatible types
  16. addEventListener(EventType.Mouse, (e: number) => console.log(e));

Also makes Array<Child> assignable to Array<Base> (covariance) as the functions are compatible. Array covariance requires all Array<Child> functions to be assignable to Array<Base> e.g. push(t:Child) is assignable to push(t:Base) which is made possible by function argument bivariance.

This can be confusing for people coming from other languages who would expect the following to error but will not in TypeScript:

  1. /** Type Heirarchy */
  2. interface Point2D { x: number; y: number; }
  3. interface Point3D { x: number; y: number; z: number; }
  4. /** Two sample functions */
  5. let iTakePoint2D = (point: Point2D) => { /* do something */ }
  6. let iTakePoint3D = (point: Point3D) => { /* do something */ }
  7. iTakePoint3D = iTakePoint2D; // Okay : Reasonable
  8. iTakePoint2D = iTakePoint3D; // Okay : WHAT

Enums

  • Enums are compatible with numbers, and numbers are compatible with enums.
  1. enum Status { Ready, Waiting };
  2. let status = Status.Ready;
  3. let num = 0;
  4. status = num; // OKAY
  5. num = status; // OKAY
  • Enum values from different enum types are considered incompatible. This makes enums useable nominally (as opposed to structurally)
  1. enum Status { Ready, Waiting };
  2. enum Color { Red, Blue, Green };
  3. let status = Status.Ready;
  4. let color = Color.Red;
  5. status = color; // ERROR

Classes

  • Only instance members and methods are compared. constructors and statics play no part.
  1. class Animal {
  2. feet: number;
  3. constructor(name: string, numFeet: number) { /** do something */ }
  4. }
  5. class Size {
  6. feet: number;
  7. constructor(meters: number) { /** do something */ }
  8. }
  9. let a: Animal;
  10. let s: Size;
  11. a = s; // OK
  12. s = a; // OK
  • private and protected members must originate from the same class. Such members essentially make the class nominal.
  1. /** A class hierarchy */
  2. class Animal { protected feet: number; }
  3. class Cat extends Animal { }
  4. let animal: Animal;
  5. let cat: Cat;
  6. animal = cat; // OKAY
  7. cat = animal; // OKAY
  8. /** Looks just like Animal */
  9. class Size { protected feet: number; }
  10. let size: Size;
  11. animal = size; // ERROR
  12. size = animal; // ERROR

Generics

Since TypeScript has a structural type system, type parameters only affect compatibility when used by member. For example, in the following T has no impact on compatibility:

  1. interface Empty<T> {
  2. }
  3. let x: Empty<number>;
  4. let y: Empty<string>;
  5. x = y; // okay, y matches structure of x

However if T is used, it will play a role in compatibility based on its instantiation as shown below:

  1. interface NotEmpty<T> {
  2. data: T;
  3. }
  4. let x: NotEmpty<number>;
  5. let y: NotEmpty<string>;
  6. x = y; // error, x and y are not compatible

In cases where generic arguments haven’t been instantiated they are substituted by any before checking compatibility:

  1. let identity = function<T>(x: T): T {
  2. // ...
  3. }
  4. let reverse = function<U>(y: U): U {
  5. // ...
  6. }
  7. identity = reverse; // Okay because (x: any)=>any matches (y: any)=>any

Generics involving classes are matched by relevant class compatability as mentioned before. e.g.

  1. class List<T> {
  2. add(val: T) { }
  3. }
  4. class Animal { name: string; }
  5. class Cat extends Animal { meow() { } }
  6. const animals = new List<Animal>();
  7. animals.add(new Animal()); // Okay
  8. animals.add(new Cat()); // Okay
  9. const cats = new List<Cat>();
  10. cats.add(new Animal()); // Error
  11. cats.add(new Cat()); // Okay

FootNote: Invariance

We said invariance is the only sound option. Here is an example where both contra and co variance are shown to be unsafe for arrays.

  1. /** Hierarchy */
  2. class Animal { constructor(public name: string){} }
  3. class Cat extends Animal { meow() { } }
  4. /** An item of each */
  5. var animal = new Animal("animal");
  6. var cat = new Cat("cat");
  7. /**
  8. * Demo : polymorphism 101
  9. * Animal <= Cat
  10. */
  11. animal = cat; // Okay
  12. cat = animal; // ERROR: cat extends animal
  13. /** Array of each to demonstrate variance */
  14. let animalArr: Animal[] = [animal];
  15. let catArr: Cat[] = [cat];
  16. /**
  17. * Obviously Bad : Contravariance
  18. * Animal <= Cat
  19. * Animal[] >= Cat[]
  20. */
  21. catArr = animalArr; // Okay if contravariant
  22. catArr[0].meow(); // Allowed but BANG ? at runtime
  23. /**
  24. * Also Bad : covariance
  25. * Animal <= Cat
  26. * Animal[] <= Cat[]
  27. */
  28. animalArr = catArr; // Okay if covariant
  29. animalArr.push(new Animal('another animal')); // Just pushed an animal into catArr!
  30. catArr.forEach(c => c.meow()); // Allowed but BANG ? at runtime