2

(Originally wanting to open a bug report in the issue tracker of the TypeScript repo, I realized that I asked too many questions, so I am opening a question here before the bug report.)

Playground link with relevant code

type Foo0 = {} extends Record<string, unknown> ? true : false;
//   ^? [type Foo0 = true]
type Foo1 = Record<string, unknown> extends {} ? true : false;
//   ^? [type Foo1 = true]

type Bar0 = {} extends object ? true : false;
//   ^? [type Bar0 = true]
type Bar1 = object extends {} ? true : false;
//   ^? [type Bar1 = true]

type Baz0 = Record<string, unknown> extends object ? true : false;
//   ^? [type Baz0 = true]
type Baz1 = object extends Record<string, unknown> ? true : false;
//   ^? [type Baz1 = false]

Edited Question: I guess I've got the point. In Foo0 and Bar0, {} represents “an empty object (a record without any values)”, so it's assignable to both object (which represents “any non-primitive”) and Record<string, unknown> (which is equivalent to { [key: string]: unknown }). On the other hand, {} in Foo1 and Bar1 represents “any non-nullish value”, thus both object and Record<string, unknown> are assignable to it. So I guess my question would become:

Why {} represents two different things? Is it intentional? If it's due to historical reasons, is there any chance that these two things get separated out?

Do point them out if I got something wrong.

graphemecluster
  • 301
  • 1
  • 9
  • 2
    Could you modify this to ask a single, primary question? It makes sense that you'd have several related questions, but in order for this to be suitable for Stack Overflow it should be possible to post an answer to a single question and have it be accepted. – jcalz Jul 02 '22 at 17:33
  • On "A assignable to B and B assignable to C", see https://github.com/microsoft/TypeScript/issues/45335#issuecomment-894532282. I don't understand this well enough to explain it, but the example they give is simpler & you might get helpful search keywords from that discussion. – Emily Jul 03 '22 at 00:03
  • @jcalz Thanks for the comment. I finally got the point and I've edited the question accordingly. – graphemecluster Jul 04 '22 at 08:08
  • @Artyom I like this example. It's clear and concise. Thanks for it and please also update your answer consequently. Sorry for the trouble! – graphemecluster Jul 04 '22 at 08:08
  • @graphemecluster unfortunately I will not update the answer because I have already spent more time than I wanted on it. But feel free to edit it very liberally. Or to post a new answer. – Emily Jul 07 '22 at 17:20

1 Answers1

2

I don't understand TS enough to actually solve this, but I will offer some observations from the things I managed to look up.

First of all: object is any non-primitive. So, eg. functions are objects and strings aren't:

let bar1: object = alert // typechecks
let bar2: object = 'a' // doesn't

The special property of {}, on the other hand, is "assignable from any non-null/undefined value": https://gist.github.com/OliverJAsh/381cd397008309c4a95d8f9bd31adcd7?permalink_comment_id=2890808#gistcomment-2890808

{} is the empty type and is assignable from any non-null/non-undefined type. Remember structural typing here: Reducing the number of properties in a type (should) never decrease the number of types which are assignable to it (n.b. excess property checking is not a factor in assignability; it is a separate check).

Note some problematic symmetry here: string should be assignable to { length: number }, and for any type { r0; r1; r2...}, a value assignable that type should also be assignable to { r1; r2...}. So if string is assignable to { length: number } (which it absolutely should be), then string must also be assignable to { }, even though it's not an object.

So here's the difference between {} and object, illustrated:

let bar0: {} = 'a' // typechecks
let bar1: object = 'a' // doesn't

This means that one demonstrably suspicious part is {} extends object:

let bar0: {} = 'a'
let bar1: object = {}
bar1 = bar0 // shouldn't work but it does

https://github.com/microsoft/TypeScript/pull/49119 is about the other direction — assignability of things to {}. I searched but haven't found anything about assigning {} to things.

I'm not sure whether TS actually cares about upholding the contract that a primitive (like string) can't end up in an object. If it does care, then as you mentioned, there should be one false in each of the first two pairs.

But ok. Let's say it doesn't care.

The second iffy part is the disparity between:

{}     extends Record<string, unknown> = true
object extends Record<string, unknown> = false

What does a TypeScript index signature actually mean? might provide a clue re/ why the second one is false.

I have spent the last hour trying to understand index signatures (which are used by Record) and I can't come up with a clear semantics for them that makes sense.

Perhaps the only good explanation is "TS wants to complain if it's very obvious that you are about to inject wrongly-typed keys/values, but otherwise it won't protest too much". So object is a sort of a rubbish pile that shouldn't end up in a {[key: string]: unknown}, but {} is fine even though it is potentially absolutely also a rubbish pile.

I wish TS's typechecking rules were actually written down somewhere.

Emily
  • 2,577
  • 18
  • 38
  • I think running the type checker with a debugger attached and figuring out what the difference is here https://github.com/microsoft/TypeScript/blob/main/src/compiler/checker.ts is the only way to go forward. Well, other than someone who already knows what's in there pointing us in the right direction. – Sergiu Paraschiv Jul 04 '22 at 08:11
  • I also think the reason there is no written documentation is because it would be impossible to maintain it. There are just too many emergent behaviors to keep track of. – Sergiu Paraschiv Jul 04 '22 at 08:13
  • @SergiuParaschiv I am able to run the debugger myself but I am not familiar with the checker. Do you know which lines the relevant stuffs are in? – graphemecluster Jul 05 '22 at 10:32