I have a utility type that checks if a given type has no properties:
// (Incorrectly) tests if T is empty
type IsEmpty<T> = Record<string, never> extends T ? true : false
However, that test will return true if an object with only optional properties is tested:
type MyMixedType = {
a: number,
b?: number,
}
// Picks the required property only, won't be empty (test will be false)
type testA = IsEmpty<Pick<MyMixedType, 'a'>>
// Picks the optional property only, should not be empty, but test is true
type testB = IsEmpty<Pick<MyMixedType, 'b'>>
My question is: Why? Why does Record<string, never> extends {b?: number}
? Or any object with only optional properties, for that matter.
The particular example you've chosen is intentional, since never
is assignable to anything, but the overall situation is considered a bug in TypeScript, as per microsoft/TypeScript#27144... but that bug is probably never going away, so you can probably just consider it a design limitation at this point.
TypeScript is intentionally unsafe when it comes to optional properties and to index signatures. Object types missing such members are assignable to objects with them. For example, {a: string}
is assignable to both {a: string, [k: string]: string }
and to {a: string, b?: string}
. That's technically unsafe; something like {a: "abc", b: boolean}
is assignable to {a: string}
, and therefore a series of allowed assignments can result in runtime explosions:
const orig = { a: "abc", b: false };
const widen: { a: string } = orig; // okay, considered safe
const indexSig: { a: string, [k: string]: string } = widen; // allowed, but unsafe!
indexSig.b?.toUpperCase(); // compiles, but RUNTIME ERROR!
const optional: { a: string, b?: string } = widen; // allowed, but unsafe!
optional.b?.toUpperCase() // compiles, but RUNTIME ERROR!
That's unfortunate, but it's intentional. The following is just too convenient to give up:
const foo = { a: "abc" };
const bar: { a: string, b?: string } = foo; // okay
If they made the optional
assignment above invalid, then the bar
assignment would also be invalid for the same reason. People would hate that.
Usually this isn't a big deal, because assignability in TypeScript isn't necessarily transitive, and if you make direct assignments instead of an indirect series, you get the expected errors:
const indexSig: { a: string, [k: string]: string } = orig; // error!
const optional: { a: string, b?: string } = orig; // error!
So it's intentional for something like Record<string, XXX>
to be assignable to {b?: XXX}
, and probably for Record<string, never>
to be assignable to {b?: number}
since never
is assignable to number
.
The above is all as intended. The bug is that TypeScript allows you to cross-assign index signatures to optional properties, even when the property types directly contradict. That means a direct assignment that should obviously fail is allowed:
let indexSig: { a: string, [k: string]: string } = { a: "abc", b: "def" }; // okay
let optional: { a: string, b?: number } = { a: "abc", b: 123 }; // okay
optional = indexSig; // ALLOWED?!
optional.b?.toFixed(); // compiles, but RUNTIME ERROR!
It's filed as microsoft/TypeScript#27144, and it's not good. They should fix it, right?
Well they tried to implement a fix for it at microsoft/TypeScript#27591. And unfortunately it interacted poorly with some real world code, and it's not clear what should be done about that. The situation is as described in this comment:
From my observation, this change is:
Technically correct in most (probably all?) cases.
But not practically useful in the majority of cases.
Hard to recover from in many cases.
I vote not to take this change unless I see something that changes my mind. For example:
Data that show a majority of good, easy-to-understand bugs being caught.
An easy explanation of how to fix the errors mechanically.
And nothing happened after that. So, for better or worse, index signatures are assignable to optional properties, even when the property type is incompatible. Thus not only is Record<string, never>
assignable to {b?: number}
(which is intentional), but also Record<string, string>
is assignable to {b?: number}
(which is unintentional but apparently not changing anytime soon).