microsoft / TypeScript

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

Generic parameter type checking stops too early #59049

Open devongovett opened 2 weeks ago

devongovett commented 2 weeks ago

🔎 Search Terms

generic, early, permature, defer

🕗 Version & Regression Information

⏯ Playground Link

https://www.typescriptlang.org/play/?ssl=10&ssc=1&pln=1&pc=1#code/JYOwLgpgTgZghgYwgAgEoRgeQEYCsIJgA8AKgHzIDeAUMncggK5RQTgBcyJyAPsiIwA2g6gF9q1ACYFBcVshiMQhYAHsQySAGcwACgAendFjwFiAgLbZoZAJScAbquCSA3FJlyUi5WDUaAcwgwY117NAwcfEIiS2soXn4hQTJ3akFg5AdkAF5kIJCMMPdtPQdbdyA

💻 Code

interface RefObject<T> {
    current: T | null
}

declare function test(x: RefObject<number>): void;
declare function getRef(): RefObject<number | null>;

let v = getRef();
test(v);

🙁 Actual behavior

In the above code, RefObject is defined to always accept null in its current property. However, if one explicitly writes RefObject<number | null> and passes the result to a parameter accepting RefObject<number> TS reports a type error even though the types of the current property would have been the same. This happens with any type, not just null (e.g. if the definition of current was T | string.

🙂 Expected behavior

Since the current properties are equivalent after substituting the generic parameters, there should be no error. It seems like TS is stopping as soon as it sees RefObject<number | null> vs RefObject<number> even though substituting the generic parameter would result in the same type. This can be confirmed by creating a copy of the RefObject type like this:

interface RefObject<T> {
    current: T | null
}

interface RefObject2<T> {
    current: T | null
}

declare function test(x: RefObject<number>): void;
declare function getRef(): RefObject2<number | null>;

let v = getRef();
test(v);

Playground

Additional information about the issue

This affects the change in React's types between version 18 and 19, where the RefObject type changed current from T | null to only T. The uses of RefObject should add null instead, e.g. RefObject<HTMLDivElement | null>. Making libraries compatible with both the React 18 and React 19 types is difficult due to this TS issue. If a library updates its implementation to React 19, including RefObject<HTMLElement | null> in its definitions, consumers still using React 18 types will see TS errors.

cc. @eps1lon in case you know about this

RyanCavanaugh commented 2 weeks ago

This is tricky. By default this is done with a variance-based short circuiting; not doing this would be catastrophic to performance. Since it's only needed to probe the type deeper if it's the case that T is only referenced in places where it's unioned with null, a better workaround would be to do something like this

type RefObject<T> = _RefObject<Exclude<T, null>>;
interface _RefObject<T> {
    current: T | null
}
devongovett commented 2 weeks ago

Ah interesting. I figured it was for performance. I guess your workaround would need to be added to the old versions of the React types (e.g. 18, 17, 16, etc), since React 19 removed the | null. Not sure if they'd be open to that and it would require everyone update to a new patch.

The other alternative we were considering for our library was to have our own copy of RefObject locally and use that everywhere instead of the one imported from react. That should also work but it means that each library that wants to maintain compatibility with multiple React versions will need to do that.

eps1lon commented 2 weeks ago

Not sure if they'd be open to that and it would require everyone update to a new patch.

If it's not breaking, you can file a PR and we see if it integrates cleanly.

type RefObject<T> = _RefObject<Exclude<T, null>>;
interface _RefObject<T> {
  current: T | null
}

This is just really gnarly and given how unpredictable type emission is in TypeScript is with numerous issues closed as intended or left unaddressed, I'd want to consider first if we want to merge this. There are already enough types to deal with as learners and adding some weird _ prefixed types just makes it worse if they leak into user code.

devongovett commented 2 weeks ago

Yeah... maybe ok if it was an internal type but still not ideal that projects would need to upgrade the 16/17/18 types in order to fix compatibility with libraries built for the 19 types either.

Would your recommendation for libraries that need to support multiple React versions be to make their own copy of RefObject like we're thinking of doing in React Aria? https://github.com/adobe/react-spectrum/pull/6632

eps1lon commented 2 weeks ago

not ideal that projects would need to upgrade the 16/17/18 types in order to fix compatibility with libraries built for the 19 types either.

I don't think this is an issue. I think if we can't even recommend updating libraries by a patch version, we've lost the ability to move the ecosystem forward. We can recommend upgrading React types by a patch once, or teach each library how to add a compat layer and then rely on consumers to upgrade each of these libraries? Seems way more simple to fix this in React types.

But if it requires this workaround that may leak into emitted types, it doesn't seem like a good workaround. Regardless of whether we do this in React types or 3rd party libraries. It would be more helpful if TypeScript would either fix this or improve type emission so that libraries are in control of what types are public and private.

Why can't you upgrade all your callsites of RefObject to use RefObject<T | null> right now ignoring anything that comes in React 19? You said it causes errors but I'm not seeing any in the playground nor do I see an explainer why it should error. The sample code you posted could be fixed if all RefObject usages would use RefObject<T | null>.

devongovett commented 2 weeks ago

Why can't you upgrade all your callsites of RefObject to use RefObject<T | null> right now

Yeah that's what we've done. The problem occurs when passing a ref typed as RefObject<T | null> to anything that accepts React.RefAttributes<T> in previous versions of React, including builtin JSX elements like <div>, and any forwardRef components.

Here's a more practical example of what I mean: Playground

In React 19, ForwardedRef and RefAttributes include RefObject<T | null> but in previous versions of React, the | null is inside the definition of RefObject. So due to this TS limitation, if we add T | null to our definition, we are compatible with React 19 but not 18 or previous, and if we don't add it we are compatible with previous but not 19.

eps1lon commented 2 weeks ago

That is a problem that I would love being able to fix in React types. But this _RefObject types appears in emitted declarations and we just created another problem that'll take years to get rid of when TypeScript changes behavior in that area. And there's the cost of having to learn/teach why we have this intermediate type as well.

devongovett commented 2 weeks ago

Yeah I agree. Ideally TS would fix this issue and prioritize correctness over performance. But in the meantime we'll need a recommendation because I would imagine we are not the only library that will be affected by this.

Another option I thought of would be to add a new NullableRefObject type in both old and new versions of the React types. In old react it could be an alias:

// In old react types
type NullableRefObject<T> = RefObject<T>;

and in React 19 it could add null to T:

// In React 19
type NullableRefObject<T> = RefObject<T | null>;

Then libraries could use NullableRefObject everywhere. Perhaps slightly less hacky but still requires updating many versions of the types, and teaching a new type. 🤔

RyanCavanaugh commented 4 days ago

I'm not endorsing this as a great solution, but it works to get structural comparison as a fallback (thus preserving the perf benefits of variance measurement)

declare const UnreliableSymbol: unique symbol;
type Struct<T> = `${T & string}`; 

type DataOrNull< /* unreliable out */ T> = {
  data: T | null;
  [UnreliableSymbol]: Struct<T>;
}

declare function check(s: DataOrNull<string>): void;

declare let foo: DataOrNull<string | null>;
check(foo); // ok