Open devongovett opened 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
}
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.
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.
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
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>
.
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.
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.
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. 🤔
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
🔎 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
🙁 Actual behavior
In the above code,
RefObject
is defined to always acceptnull
in itscurrent
property. However, if one explicitly writesRefObject<number | null>
and passes the result to a parameter acceptingRefObject<number>
TS reports a type error even though the types of thecurrent
property would have been the same. This happens with any type, not just null (e.g. if the definition of current wasT | 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 seesRefObject<number | null>
vsRefObject<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:Playground
Additional information about the issue
This affects the change in React's types between version 18 and 19, where the
RefObject
type changedcurrent
fromT | null
to onlyT
. The uses ofRefObject
should addnull
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, includingRefObject<HTMLElement | null>
in its definitions, consumers still using React 18 types will see TS errors.cc. @eps1lon in case you know about this