admin管理员组

文章数量:1315258

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'>>

TS Playground link

My question is: Why? Why does Record<string, never> extends {b?: number} ? Or any object with only optional properties, for that matter.

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'>>

TS Playground link

My question is: Why? Why does Record<string, never> extends {b?: number} ? Or any object with only optional properties, for that matter.

Share Improve this question edited Jan 30 at 8:24 jonrsharpe 122k30 gold badges267 silver badges474 bronze badges asked Jan 30 at 8:06 HolzchopfHolzchopf 4072 silver badges10 bronze badges 5
  • 1 Given that Record<string, never> extends {}, and {} extends {b?: number}, I don't see why you would expect a different result. – Robby Cornelissen Commented Jan 30 at 8:16
  • 1 Maybe you intended to invert the conditional type? I.e. type IsEmpty<T> = T extends Record<string, never> ? true : false – Robby Cornelissen Commented Jan 30 at 8:33
  • @RobbyCornelissen I thought {} was the quite wide type "any non nullish value"? How can the Record<string, never> extends {} detour be valid then? That feels like multiplying both sides in a mathematical equation by 0. – Holzchopf Commented Jan 30 at 10:09
  • 1 This is a longstanding bug ms/TS#27144. Neither optional properties nor index signatures are fully type safe (missing things are assignable to them, so e.g., {a: string} is assignable to {a: string, b?: number} and {a: string} is assignable to {a: string, [k: string]: string | number}, which is unsafe but very convenient) and this interacts badly which each other in the case of incompatible types like Record<string, string> extends {a?: number}. Does that fully address the q? If so I'll write an answer. If not, what's missing? – jcalz Commented Jan 30 at 13:36
  • Ah I see know, thanks. Yes that would make a good answer. – Holzchopf Commented Jan 31 at 7:35
Add a comment  | 

1 Answer 1

Reset to default 1

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:

  1. Technically correct in most (probably all?) cases.

  2. But not practically useful in the majority of cases.

  3. Hard to recover from in many cases.

I vote not to take this change unless I see something that changes my mind. For example:

  1. Data that show a majority of good, easy-to-understand bugs being caught.

  2. 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).

Playground link to code

本文标签: typescriptWhy does Recordltstringnevergt extends b numberStack Overflow