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
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With