This page shows the source for this entry, with WebCore formatting language tags and attributes highlighted.


The weird world of type-compatibility in TypeScript


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. <n><abbr title="too long; didn't read">tl;dr</abbr>: there is no TypeScript compiler bug, but my faith in the TypeScript language's type model is badly shaken.</n> <h>A simple example</h> The following code compiles---and well it should. <code> interface IB { name: string; } interface IA { f(action: (p: IB) => void): IA; } class A implements IA { f = (action: (p: IB) => void): IA => { return this; } } </code> Some notes on this example: <ul> The shape of interface <c>IB</c> isn't relevant to the discussion. The intent of interface <c>IA</c> is to require implementors to define a method named <c>f</c> that takes single parameter of type <c>(IB) => void</c> and returns <c>IA</c>. The implementation <c>A</c> above satisfies this requirement. It doesn't do anything with parameter <c>action</c> but that's OK. The definition of <c>A.f()</c> is what a naive user of TypeScript would assume was the only way of satisfying the requirement from <c>IA</c> </ul> <h>Oddly compatible lambdas</h> However, the following implementations of <c>IA</c> also compile. <code> 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; } } </code> <h>Forcing incompatibility</h> The only one I tried that doesn't compile is shown below. <code> class A6 implements IA { f = (action: (p: number) => void): IA => { return this; } } </code> In this case, the TypeScript compiler rightly shows the following error: <img src="{att_link}compileerror.png" scale="50%"> Hovering over the class name <c>A5</c> shows the following tooltip: <bq><pre>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'. </pre></bq> To summarize, the following types seem to be compatible with <c>(IB) => void</c>: <ul> <c>() => IB</c> <c>(IB) => IB</c> <c>() => void</c> <i>No parameter at all</i> </ul> <h>The nitty-gritty of TypeScript's type system</h> 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 <a href="">Type compatibility</a> documentation for TypeScript. This section starts off with a <iq>Note on Soundness</iq>, which contains a note that suggests that what we have above is completely valid TypeScript. <bq>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.</bq> The section <i>Comparing two functions</i> 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, <i>but the number of parameters doesn't have to match</i>. So if the target type has 4 parameters and the lambda to assign has 0 parameters, that lambda is compatible. From the manual: <code> let <macro convert="-punctuation">x<macro convert="+punctuation"> = (a: number) => 0; let y = (b: number, s: string) => 0; y = x; // OK x = y; // Error </code> For return types, the matching behavior is <i>opposite</i>. That is, a "bigger" type that satisfies the expected return type is just fine. <code> let <macro convert="-punctuation">x<macro convert="+punctuation"> = () => ({name: "Alice"}); let y = () => ({name: "Alice", location: "Seattle"}); x = y; // OK y = x; // Error because x() lacks a location property </code> <h>Reëxamining the oddly compatible lambdas</h> 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 <c>(IB) => void</c>: <ul> <c>f(() => IB): IA</c>: this is compatible because the zero parameters conform by definition and any return type is OK because <c>void</c> is expected. <c>f((IB) => IB): IA</c>: this is compatible because the single parameter conforms and any return type is OK because <c>void</c> is expected. <c>f(() => void): IA</c>: this is compatible because because the zero parameters conform by definition and any return type is OK because <c>void</c> is expected. <c>f() => IA</c>: this one looks plain wrong at first, but the same logic applies to the whole function <c>f((IB) => void) => IA</c> instead of to the lambda parameter for it. The interface expects a function <c>f</c> with a single parameter, returning <c>IA</c>. By the first rule above, a function with zero parameters satisfies that requirement. <c>f((number) => void): IA</c>: This does not satisfy the requirement because <c>number</c> is not compatible with <c>IB</c>. <c>f(number): IA</c>: This does not satisfy the requirement because <c>number</c> is not compatible with <c>(IB) => void</c>. <c>f(): void</c>: This does not satisfy the requirement because while zero parameters is OK, the type <c>void</c> is smaller than <c>IA</c>. </ul> 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 <iq>allow[ing] unsound behavior [in] carefully considered [places]</iq> 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. <h>Actual vs. Formal Arguments</h> When I read <a href="" source="Amazon"><abbr title="Object-Oriented Software Construction">OOSC2</abbr></a> a long time ago<fn>, I remember how Bertrand Meyer made the distinction between the <i>formal</i> type of an argument (the type in the method signature) and the <i>actual</i> type of an argument (the runtime type). The method-type--conformance rules for TypeScript make sense for <i>actual</i> arguments. They ensure compatibility with JavaScript. What's not clear to me is that this same logic be applied to <i>formal</i> 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. <hr> <ft>I'm a nerd, I read all 1300 pages twice.</ft>