The weird world of type-compatibility in TypeScript

Published by marco on

I recently fixed a bug in some TypeScript code that compiled just fine—but it looked for all the world like it shouldn’t have.

tl;dr: there is no TypeScript compiler bug, but my faith in the TypeScript language’s type model is badly shaken.

A simple example

The following code compiles—and well it should.

interface IB {
  name: string;
}

interface IA {
  f(action: (p: IB) => void): IA;
}

class A implements IA {
  f = (action: (p: IB) => void): IA => {
    return this;
  }
}

Some notes on this example:

  • The shape of interface IB isn’t relevant to the discussion.
  • The intent of interface IA is to require implementors to define a method named f that takes single parameter of type (IB) => void and returns IA.
  • The implementation A above satisfies this requirement. It doesn’t do anything with parameter action but that’s OK.
  • The definition of A.f() is what a naive user of TypeScript would assume was the only way of satisfying the requirement from IA

Oddly compatible lambdas

However, the following implementations of IA also compile.

class A2 implements IA {
  f = (action: () => IB): IA => {
    return this;
  }
}

class A3 implements IA {
  f = (action: (p: IB) => IB): IA => {
    return this;
  }
}

class A4 implements IA {
  f = (action: () => void): IA => {
    return this;
  }
}

class A5 implements IA {
  f = (): IA => {
    return this;
  }
}

Forcing incompatibility

The only one I tried that doesn’t compile is shown below.

class A6 implements IA {
  f = (action: (p: number) => void): IA => {
    return this;
  }
}

In this case, the TypeScript compiler rightly shows the following error:

Hovering over the class name A5 shows the following tooltip:

Class ‘A5’ incorrectly implements interface ‘IA’.
  Types of property ‘f’ are incompatible.
    Type ‘(action: (p: number) => void) => IA’ is not assignable to type ‘(action: (p: IB) => void) => IA’.
      Types of parameters ‘action’ and ‘action’ are incompatible.
        Type ‘(p: IB) => void’ is not assignable to type ‘(p: number) => void’.
          Types of parameters ‘p’ and ‘p’ are incompatible.
            Type ‘number’ is not assignable to type ‘IB’.

To summarize, the following types seem to be compatible with (IB) => void:

  • () => IB
  • (IB) => IB
  • () => void
  • No parameter at all

The nitty-gritty of TypeScript’s type system

In a more strongly typed language like C#, it’s clear that none of this would fly. But this is TypeScript, which defines its typing model on compatibility with the dynamic language JavaScript.

It almost looks like the type of the lambda isn’t part of the type signature of the method, which came as a quite a surprise to me (and also to my colleague, Urs, who is much more of a TypeScript expert than I am).

But maybe we don’t know enough about the TypeScript type system. Let’s look at the Type compatibility documentation for TypeScript.

This section starts off with a “Note on Soundness”, which contains a note that suggests that what we have above is completely valid TypeScript.

“The places where TypeScript allows unsound behavior were carefully considered, and throughout this document we’ll explain where these happen and the motivating scenarios behind them.”

The section Comparing two functions starts off explaining some rather surprising things about the type-compatibility of functions: for a function to be type-compatible with another function, the types of its parameters must match the types of the target type’s parameters, but the number of parameters doesn’t have to match. So if the target type has 4 parameters and the lambda to assign has 0 parameters, that lambda is compatible.

From the manual:

let x = (a: number) => 0;
let y = (b: number, s: string) => 0;

y = x; // OK
x = y; // Error

For return types, the matching behavior is opposite. That is, a “bigger” type that satisfies the expected return type is just fine.

let x = () => ({name: "Alice"});
let y = () => ({name: "Alice", location: "Seattle"});

x = y; // OK
y = x; // Error because x() lacks a location property

Reëxamining the oddly compatible lambdas

Armed with this new knowledge, let’s see if the previously bizarre-seeming behavior is actually valid.

To recap, the TypeScript compiler says that following signatures are compatible with (IB) => void:

  • f(() => IB): IA: this is compatible because the zero parameters conform by definition and any return type is OK because void is expected.
  • f((IB) => IB): IA: this is compatible because the single parameter conforms and any return type is OK because void is expected.
  • f(() => void): IA: this is compatible because because the zero parameters conform by definition and any return type is OK because void is expected.
  • f() => IA: this one looks plain wrong at first, but the same logic applies to the whole function f((IB) => void) => IA instead of to the lambda parameter for it. The interface expects a function f with a single parameter, returning IA. By the first rule above, a function with zero parameters satisfies that requirement.
  • f((number) => void): IA: This does not satisfy the requirement because number is not compatible with IB.
  • f(number): IA: This does not satisfy the requirement because number is not compatible with (IB) => void.
  • f(): void: This does not satisfy the requirement because while zero parameters is OK, the type void is smaller than IA.

Well, it looks like there’s nothing to see here, folks. The compiler is doing exactly what it’s supposed to. Move along and get on with your day.

Unfortunately, that means that TypeScript is going to be considerably less helpful for ensuring program correctness than I’d previously thought.

In fact, the caveat about Typescript “allow[ing] unsound behavior [in] carefully considered [places]” seems a bit disingenuous because, to a programmer accustomed to something like C# or Java or Swift, this kind of type-enforcement for method compatibility cannot be relied upon to enforce much of anything.

Actual vs. Formal Arguments

When I read OOSC2 (Amazon) a long time ago[1], I remember how Bertrand Meyer made the distinction between the formal type of an argument (the type in the method signature) and the actual type of an argument (the runtime type).

The method-type–conformance rules for TypeScript make sense for actual arguments. They ensure compatibility with JavaScript. What’s not clear to me is that this same logic be applied to formal arguments that are only available in TypeScript. If I declare a specific type signature in an interface, what are the odds that I want the wishy-washy JavaScript-friendly type rules for those situations? From an architect’s point of view, it would certainly be nicer to have more strict type-checking for formal definitions.

Since we don’t have that, this very lenient type-compatibility renders type-checking for lambdas largely useless in interface declarations. The compiler won’t be able to tell you that your implementation no longer matches the interface declaration because almost anything you write will actually match.


[1] I’m a nerd, I read all 1300 pages twice.