microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
101.24k stars 12.52k forks source link

Coercion from {} to object is unsound #52447

Closed andersk closed 1 year ago

andersk commented 1 year ago

Bug Report

🔎 Search Terms

coercion, object, typeof, unsound

🕗 Version & Regression Information

(I am of course aware that TypeScript has deliberately chosen to allow certain areas of unsoundness, but this doesn’t look like one of those deliberate choices.)

⏯ Playground Link

Playground link with relevant code

💻 Code

let a: string = "s";
let b: {} = a;
let c: object = b;
let d: number = typeof c === "object" ? 0 : c;
console.log(d.toFixed()); // runtime crash

🙁 Actual behavior

No compile errors. TypeScript allows b: {} to be coerced to c: object, and I’ve taken advantage of this incorrect coercion to store a string in a variable typed number.

🙂 Expected behavior

TypeScript should not allow coercion from b: {} to c: object, because the contract of c: object has an extra requirement that b: {} does not, namely that typeof c === "object".

fatcerberus commented 1 year ago

This isn’t specific to {} and also happens for e.g. { toString(): string } (which clearly should be assignable to object). It’s a known soundness hole with object literal types.

andersk commented 1 year ago

@fatcerberus Is it clear that { toString(): string } should be assignable to object? Seems to me that "s" is a great example of a { toString(): string } that should not be assignable to object.

If this is a known soundness hole, do you happen to have a reference where one can learn about these known soundness holes? The only ones I’ve found in the documentation are function parameter bivariance and optional/rest parameters; I’m aware of many others but I haven’t seen them documented, so I have no idea how to tell which ones are deliberate.

fatcerberus commented 1 year ago

If { toString(): string } is not assignable to object, then what is[^1]? TS generally shies away from special cases like "object literal type is assignable to object unless it's also assignable from a primitive"; the general rules in play are that any value which is coercible to the object literal type is also assignable to it, and all object literal types are assignable to object, which are easy to reason about, even if they unfortunately lead to a soundness hole in this case.

I’m aware of many others but I haven’t seen them documented, so I have no idea how to tell which ones are deliberate.

It's not super ideal but the maintainers have said in the past that the issue tracker is considered part of the documentation w.r.t. small nuances like this.

[^1]: This isn't just me trying to be difficult. A more subtle case would be, e.g. { valueOf(): number, x?: string, y?: number }. Primitive numbers are assignable to it, but it would be super confusing if such a type wasn't assignable to object, since that's what it's probably going to be 99.9% of the time. TS tries to be pragmatic about this kind of thing.

fatcerberus commented 1 year ago

On the above note re: documentation, I was almost positive this exact issue had been reported and closed working-as-intended before, but I can't find it now.

FWIW I think it was a mistake for primitives to be assignable to object types at all, but alas, we're stuck with it now.

andersk commented 1 year ago

If { toString(): string } is not assignable to object, then what is?

Is that question fundamentally any different from “if { valueOf(): boolean } is not assignable to boolean, then what is?”

Values that should be assignable to boolean include true and false. Values that should be assignable to object include object literal values like {x: "s", y: 1} (as values, not as types), some other literals like ["arrays"] and /^regexps$/, and class instances like new Set(). One should not expect an arbitrary member of a structural type to be assignable to object any more than one should expect it to be assignable to boolean. (That’s what unknown is for.)

An even more direct argument is that any structural type can be subclassed with a call signature inhabited only by functions, for which typeof returns "function", not "object".

fatcerberus commented 1 year ago

No structural type is assignable to a primitive; all structural types are assignable to object. TS is very consistent about that; unsoundness arises because primitives are assignable to some structural types. This IMO was a design mistake, but I have enough experience following issues here to know it’s not likely to change now.

Unless you have some affiliation with the TypeScript team that GitHub isn’t showing me, that’s for the TypeScript team to decide

Like I said, this exact issue has been reported before and declared as by design; I just can’t find the dupe at the moment. It’s already been decided and I’m just passing the message along.

andersk commented 1 year ago

No structural type is assignable to a primitive; all structural types are assignable to object. TS is very consistent about that;

I understand this is the current behavior, and you think it’s consistent and the problem is elsewhere, but what I’m saying that it’s not consistent.

If it were only a question about whether a primitive string should be considered a member of {}, we could go either way about where the problem is. But consider this variant of my test with a function instead of a string. It similarly compiles with no errors and breaks at runtime:

let a: {(): void} = () => {};
let b: {} = a;
let c: object = b;
let d: number = typeof c === "object" ? 0 : c;
console.log(d.toFixed()); // runtime crash

I don’t think one can argue that () => {} should not inhabit {(): void}, or that {(): void} should not be a subtype of {}.

fatcerberus commented 1 year ago

Hmm, c is being narrowed to never in the false branch of that ternary. Even without the unsoundness mentioned in the OP (i.e. non-transitive assignability), that's wrong -- functions are directly assignable to object, and their runtime typeof is "function".

RyanCavanaugh commented 1 year ago

Allowing { } to assign to object is allowed because object is "relatively" new and there's just too much code out there that still uses { } to mean object. Maybe in a very long time we can close this hole, but now right now for sure.

unsoundness arises because primitives are assignable to some structural types.

Disagree. https://github.com/microsoft/TypeScript/issues/48988#issuecomment-1119793271

RyanCavanaugh commented 1 year ago

Also please don't snipe at my friendly triage helpers 😉

andersk commented 1 year ago

My apologies if that came across as sniping. I have deleted that part of my comment.

fatcerberus commented 1 year ago

Disagree. https://github.com/microsoft/TypeScript/issues/48988#issuecomment-1119793271

To be fair, that proof only holds because TS treats object types as “object-coercible” rather than (what I think people generally expect) “non-primitive object of shape”. Which is fine but still interesting given TS’s general stance on automatic coercion. {} notwithstanding, if I ask for something of type { … } I generally don’t expect to receive primitives just because they happen to be object-coercible to something compatible with that type (luckily this is rarely an issue in practice due to weak type checks etc.)

fatcerberus commented 1 year ago

@RyanCavanaugh Point I was trying to get at above was: it is technically unsound for any object type which is assignable from a primitive to be assignable to object. IOW this is not specific to {} and is a more general soundness hole induced by TS allowing for object coercion in the type system.

andersk commented 1 year ago

Regardless of what people expect, it is fundamentally not possible for { … } to represent only object types, given that call signatures exist and are only satisfied by function types.

If the unsound coercion from { … } to object could be eventually removed, there would be no soundness issue with coercing primitives to { … } as far as I can see.

fatcerberus commented 1 year ago

That’s not true - functions are in the domain of object by design (because they are objects). It’s probably a bug that typeof foo === "object" is considered exhaustive.

typescript-bot commented 1 year ago

This issue has been marked 'Working as Intended' and has seen no recent activity. It has been automatically closed for house-keeping purposes.