Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Is this a TypeScript bug?

Tags:

typescript

I'm not sure why the following snippet of code works...

class GroupLeader { /* snip */ };
function foo(leader: GroupLeader): void { /* snip: do stuff */ }

const isLeader = false;
const groupLeader = isLeader && new GroupLeader();

foo(groupLeader);

In the REPL, I can see groupLeader ends up being a boolean type, but there is no error generated by TypeScript compiler (version 4.4.3) when invoking foo(groupLeader).

Why does this work?

Playground

like image 330
paxos1977 Avatar asked Nov 05 '21 21:11

paxos1977


1 Answers

This is the expected behavior in TypeScript.

TypeScript's type system is largely structural as opposed to nominal. So it's the shape or structure of a type that matters, and not its name or declaration. So the TypeScript compiler can decide that type A is a subtype of B (or "A is assignable to B" or "A extends B") whether or not you mention A anywhere in the declaration of B. It only matters if the apparent properties of B have compatible properties in A:

interface A {
    x: string;
    y: number;
}

interface B {
    x: string;
}

const a: A = { x: "", y: 0 };
const b: B = a; // okay, A extends B

The last line is not an error like it would be in a nominally typed language.


Note that "apparent property" means that even some primitives like number, string, and boolean can be considered structurally compatible with object types, because JavaScript automatically wraps some primitives in wrapper objects when you index into them. So the type {length: number} is a subtype of string:

interface L { length: number };
const l: L = "hello"; // okay

because string values have an apparent length property of type number.


In TypeScript, class declarations are also treated structurally in general (although there are some cases where nominal-like typing happens, such as using instanceof to distinguish between two classes or when you add private or protected members). So if you have an empty class:

class GroupLeader { }

This class is structurally identical to the empty object type {}, an object type with no members. And so any value which can be indexed into like an object will be seen as assignable to that class:

function foo(leader: GroupLeader): void { /* snip: do stuff */ }

foo(true); // okay
foo(123); // okay
foo({}); // okay
foo(() => 3); // okay
foo(new Date()); // okay
foo(Symbol("oops")); // okay

foo(null); // error
foo(undefined); // error

Only null and undefined are not assignable to GroupLeader, because null and undefined throw runtime errors if you index into them like an object.


So that's why it's happening. Generally you want to prevent such behavior, so empty classes and empty object types are best avoided, even in example code. The (somewhat outdated) TypeScript FAQ has a bunch of entries around this, like Why are all types assignable to empty interfaces? and Why do these empty classes behave strangely?. If you want the compiler to treat two types as distinct, you should make sure they have incompatible shapes.

Adding any property that boolean doesn't share to GroupLeader will change things:

class GroupLeader { unsnip = 0 };

function foo(leader: GroupLeader): void { /* snip: do stuff */ }
foo(true); // error
foo(123); // error
foo({}); // error
foo(() => 3); // error
foo(new Date()); // error
foo(Symbol("oops")); // error

foo(new GroupLeader()); // okay

Of course the possibility still exists that something structurally compatible will be passed in:

foo({ unsnip: 123 }); // okay

You can try to prevent this if you want (a private property will do it) but the path of least resistance is to just write code that only cares about structural compatibility. Whatever foo()'s implementation is, it should only care about the structure of leader and not the declaration.

Playground link to code

like image 113
jcalz Avatar answered Nov 20 '22 07:11

jcalz