microsoft / TypeScript

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

Support WebIDL interfaces nominally (not just structurally) #59971

Closed rotu closed 1 month ago

rotu commented 1 month ago

πŸ” Search Terms

WebIDL, Nominal, Structural, Interface, platform object

βœ… Viability Checklist

⭐ Suggestion

TypeScript should provide better support for "platform objects". Right now, it only checks them structurally, which:

  1. Falsely marks incorrect code as type-correct.
  2. Provides misleading error messages and Quick Fix suggestions
  3. Does more computational work than necessary

Instead, it should:

  1. check that platform object parameters are nominally the correct type (as it does for unique symbol types)
  2. provide appropriate suggestions to fixing such mismatches

πŸ“ƒ Motivating Example

Take the following code:

/// <reference no-default-lib="true"/>
/// <reference lib="dom" />
const aSignal: AbortSignal = {}
fetch("http://example.com", {signal: aSignal})

This emits the error:

Type '{}' is missing the following properties from type 'AbortSignal': aborted, onabort, reason, throwIfAborted, and 3 more.(2740) and provides a quick fix action to "add missing properties".

  1. Both are misleading. The problem is that aSignal is NOT a platform object and that its "inherited interfaces" (in the WebIDL sense) does not include AbortSignal.
  2. Adding the "missing members" shows no errors, even though the code is still incorrect.
  3. Any work in checking if an argument implements AbortSignal need not structurally examine the value.

πŸ’» Use Cases

  1. What do you want to use this for?
    • writing correct code
  2. What shortcomings exist with current approaches?
    • See above
  3. What workarounds are you using in the meantime?
    • See above
rotu commented 1 month ago

Note that instanceof and such guards are not sufficient:

let s = Object.create(AbortSignal.prototype)
// s passes an instanceof check
console.assert(s instanceof AbortSignal)
// but s is still not admissable when expecting an AbortSignal
await fetch(
  'http://example.com',
  {signal: s}
) // runtime TypeError
MartinJohns commented 1 month ago

Essentially a duplicate of #202, or at least requires that issue first.

jcalz commented 1 month ago

Even if they went through the trouble of making AbortSignal a declared class with a phantom private member, the error messages would still be "misleading" up until someone tried adding the private member and got an error message about private members and declarations. And someone who went through the trouble of trying to build an object from the scads of missing members would just work around the private member issue somehow, like with a type assertion. TS's type system just isn't set up for nominal types. Normally I'd say "use X workaround" but here it really looks like you're asking for #202.

rotu commented 1 month ago

Essentially a duplicate of #202, or at least requires that issue first.

Perhaps. That issue seems to be about user-defined types, whereas this is for platform-defined types.

Regardless, I think the machinery actually is in place to do limited nominal typing.

declare const PrimaryInterface : unique symbol

// https://webidl.spec.whatwg.org/#dfn-platform-object
type PlatformObject<in out idlInterface> = {
    /** @internal */
    readonly [PrimaryInterface] : idlInterface
}

type StructuralAbortSignal = AbortSignal
type NominalAbortSignal = PlatformObject<"AbortSignal">
type PlatformAbortSignal = NominalAbortSignal & StructuralAbortSignal

// this function will only check if signal is an "AbortSignal" - it won't suggest adding members to make it so
declare function myAbortable(signal: PlatformObject<"AbortSignal">):void;

myAbortable(null as any as PlatformAbortSignal) // okay

myAbortable(null as any as AbortSignal)
// Property '[PrimaryInterface]' is missing in type 'AbortSignal' but required in type 'PlatformObject<"AbortSignal">'.

myAbortable(null as any as {})
// Property '[PrimaryInterface]' is missing in type 'AbortSignal' but required in type 'PlatformObject<"AbortSignal">'.

myAbortable(null as any) // okay

Playground Link

snarbies commented 1 month ago

And someone who went through the trouble of trying to build an object from the scads of missing members would just work around the private member issue somehow, like with a type assertion.

I would hope and expect that even with "proper" nominal typing support, we would still be able to use type assertions when we believe we know better than the type checker. That's not really an argument against using nominal typing here, even if we have to roll our own.

jcalz commented 1 month ago

This is all my opinion, so feel free to ignore it.

I'm not trying to argue against using nominal typing, I'm saying that without officially supported nominal typing, TS's error messages imply object that type mismatches are structural in nature. They all mention that such-and-such members are missing. Currently the private class member is the closest at giving you something that feels like a message about nominal types (I think if you manage to build your own class with all the same-named private members, you'll finally see a message that looks like "the private members are declared in different places"). Even your suggestion above with the unique symbol will give error messages about the fact that that property is missing (although it doesn't auto-suggest adding the property via IntelliSense).

Personally I think "you're missing a zillion members" is as likely to deter people as "you're missing this one magical member", and neither approach is going to stop the person we're imagining guarding against. Like, when you see an object type with a Date member, would it even occur to you to try to build one structurally? Start with {} and then debug your way to something TS accepts? Who is doing that for AbortSignal? For people like this, we'd really need #202.

rotu commented 1 month ago

The problem actually is the missing "one magical member" ([[PrimaryInterface]] internal slot). @jcalz, what error message would you have?

e.g. FireFox's runtime message is:

TypeError: Window.fetch: 'signal' member of RequestInit does not implement interface AbortSignal.

Chrome:

TypeError: Failed to execute 'fetch' on 'Window': Failed to read the 'signal' property from 'RequestInit': Failed to convert value to 'AbortSignal'.

Safari:

FetchRequestInit.signal should be undefined, null or an AbortSignal object. This will throw in a future release.

jcalz commented 1 month ago

The problem at runtime is the internal slot, which isn't the same as a TS type with a symbol-valued key, and someone calling your code with myAbortable({ [PrimaryInterface]: "AbortSignal" }) will pass the TS check and still fail at runtime. I think we all agree you can currently simulate nominal typing by adding a random structural thing somewhere, but presumably native nominal typing would give the error message like "you can't assign {β‹―} to AbortSignal. A value of type AbortSignal can only come from the same declaration site as AbortSignal. You cannot assemble one from an object literal." But that's all in #202.

Anyway, I think I might be repeating myself at this point and I'm not a TS team member so my opinion isn't going to move this issue in any direction. I'll bow out for now.

rotu commented 1 month ago

Yes, it's a hack. It's less of a hack than the current behavior: relying on the structural type of an AbortSignal as a proxy for what is actually expected (a [[PrimaryInterface]] internal slot). Indeed, an API expecting a platform object could have no structure from a javascript object perspective:

e.g.

const justASignal = Object.setPrototypeOf(AbortSignal.timeout(0), null)
fetch('http://example.com', {signal:justASignal})

I guess you could say that this is structural - it's just that the identifier is not exposed to the JavaScript runtime, and a symbol-typed key, which you're not going to dereference by accident, seems the closest equivalent in TypeScript today.

There are other internal slots (e.g. [ArrayBufferData], [PromiseState]) and extended attributes (e.g. [Serializable], [Transferable]) which would be handy to have from a type system perspective.

typescript-bot commented 1 month ago

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