Skip to content

Instantly share code, notes, and snippets.

@calebmer
Created January 28, 2018 22:40
Show Gist options
  • Save calebmer/9b98ede2d88f015b998515d1c414087e to your computer and use it in GitHub Desktop.
Save calebmer/9b98ede2d88f015b998515d1c414087e to your computer and use it in GitHub Desktop.

In this case, it looks like TypeScript unifies the function type parameters. Flow does not unify function type parameters. Instead Flow defines “bounds” for function type parameters. Which is the more expressive choice when type-checking JavaScript.

Consider the following function:

function choose<T>(a: T, b: T): T {
  return Math.random() > 0.5 ? a : b;
}

choose(42, 'foo');

In this program, TypeScript produces two constraints. T = number and T = string. Since number does not equal string, TypeScript errors. But this is a completely valid program! In this case TypeScript limits expressivity for convenience.

Flow also produces two constraints. However, whereas TypeScript produced equality constraints (=). Flow produces subtyping constraints (<:). The constraints Flow produces are: number <: T and string <: T. Note the directionality! Since T is a supertype of both number and string we can say that number | string <: T. Effectively Flow has inferred that T is the union number | string.

Other functional programming languages like OCaml and Haskell also use unification. Unlike JavaScript, compiled languages like OCaml do not have anonymous runtime unions like JavaScript. That is you can’t express Int | String in Haskell without a data type.

Here’s the same function in OCaml (since OCaml allows side effects):

let choose : 'a -> 'a -> 'a = fun a b -> if random_bool () then a else b

let _ = choose 42 "foo"

Similar to TypeScript, OCaml produces two constraints. T = int and T = string. Since int is not a string, OCaml errors.

Now consider:

function choose<T>(a: T, b: (T) => void): T {
  b(a);
  return a;
}

choose(42, (x: string) => x.split(','));

Flow does error on this example! Since b(a) would call (42).split(',') which would be a runtime error! How does Flow decide that this is an error? Likewise, Flow produces two subtyping constraints. number <: T and T <: string. Notice that this time, number and string are not on the same side. So number “flows” through T and reaches string. Since number is not a subtype of string (number <: string is invalid), Flow errors.

This is where variance comes in to play. Inputs always add a lower bound to type variables (number <: T), but inputs of inputs flip and add an upper bound to type variables (T <: string).

Going back to ===, consider:

function strictEqual<T>(a: T, b: T): boolean {
  return a === b;
}

42 === 'foo';
strictEqual(42, 'foo');

TypeScript errors on both cases thanks to unification. Flow errors on neither case thanks to subtyping.

If Flow errored on 42 === 'foo', but not strictEqual(42, 'foo') that would be unsound! (Although erroring on 42 === 'foo' would be a good candidate for a lint rule. Lint rules are free to produce warnings for cases that aren’t technically runtime errors, but are likely logic errors.)

So TypeScript makes this choice for convenience. However, while this choice is not in itself unsound, it makes common JavaScript patterns like classes completely useless without some sacrifies to soundness. This decision betrays the fundamental unsoundness in TypeScript.

Ever wonder why function programming languages don’t have classes with inheritance? Or why object oriented languages don’t have inference of the same power as functional programming languages? Consider:

class Animal {}
class Cat extends Animal {}
class Dog extends Animal {}

Animal is a subtype of Cat (Cat <: Animal), but Cat is not a subtype of Animal (Animal <: Cat is invalid)! This means Cat != Animal. For two types to be equal (T = U), both types must be subtypes of each other (T <: U and U <: T).

Now consider:

function pet(x: Animal) {}

pet(new Cat());

This passes TypeScript. But if TypeScript used sound unification this should not pass TypeScript. TypeScript tries to check Cat = Animal. This fails, so then TypeScript checks both Cat <: Animal and Animal <: Cat. Since the former passes, TypeScript does not complain.

To demonstrate how this leads to invalid program construction, consider:

function pet(a: Animal, b: (x: Animal) => void) {
  b(a);
  return a;
}

pet(new Dog(), (x: Cat) => {});

TypeScript accepts this program.

Why? Well first TypeScript checks Animal = Dog. This fails. So then TypeScript checks Dog <: Animal and Animal <: Dog. The former passes and the latter fails. So TypeScript considers the first argument ok.

Then TypeScript checks Animal = Cat. This fails. So then TypeScript checks Cat <: Animal and Animal <: Cat. The former passes and the latter fails. So TypeScript considers the second argument ok.

This example fails Flow. Because Flow performs proper subtype checking. Flow checks Dog <: Animal which passes. Then Flow checks Animal <: Cat which fails. So Flow reports an error.

In trying to be convenient, TypeScript sacrifies soundness. It tries to unify and then tries to subtype. You can’t do both and also be sound.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment