I've created a minimum repro here:
https://codesandbox.io/s/react-typescript-forked-orzpet?file=/src/App.tsx
If you look at the return type of test, the only option is NewEntity, even though existing is of the type Entity | undefined. I'm not sure what i've done wrong here to make this happen. I've also included test2 to show that the edit boolean type discriminator isn't what's missing.
#TS compiler swallowing Optional type
135 messages · Page 1 of 1 (latest)
But... You are supplying a default value with the nullish coalescing operator
@scenic ingot When one type is a subtype of another, TS will infer the return type to be the common supertype, not a union.
so why does test2 show as NewEntity | undefined?
it's like it's discarding Entity even though when edit is true, existing is Entity
Because as far as TS knows, edit can be true and existing can be undefined.
can i not use edit as a type discriminator for the props?
I think the issue is the default value.
on edit?
If you get rid of the = false in edit = false, yeah.
Yeah, that's still the first thing I said: TS infers the common supertype.
type A = { a: string }
type B = { a: string, b: number }
declare const a: A;
declare const b: B;
const f1 = (x: boolean) => x ? a : b;
// ^? - const f1: (x: boolean) => A
const f2 = (x: boolean): A | B => x ? a : b;
wow okay
You can add an explicit type annotation (like f2 above) to force it to use the union return type.
that's less than useful for a react state lol
Depends on what you're doing with it.
well thats the issue
Yeah, so you're going to want to put the annotation on it.
the type signature of the save function should match what's being used
but it can't tell which one
how do i force it to recognise that saveEntity's param should match the type of the state
that's what i'm really aiming for here
You add an annotation.
and i know i can solve this easily with a generic
but i cant use one due to a hoc that the real version of this lives in
right so i need to annotate the actual function call?
how do i do that?
line 57 of the repro
also, thank you so much for the help
const test = (): NewEntity | Entity => existing ?? makeNewEntity(type);
const test2 = (): NewEntity | Entity => (edit ? existing : makeNewEntity(type));
right sorry, i've got that part in there
but the second part of using edit as the discriminator is to say what the signature of save is
it either takes a NewEntity or Entity
You're talking about saveFunc now?
and if entity is Entity, it takes Entity, if Entity is undefined, it takes NewEntity
yeah
but it seems to have lost that information
i even tried changing this to explicitly the cast the props object depending on the value of edit
and spread it inside instead of in the sig
but it just doesn't want to read it
Yeah, your state loses the 'discriminated union' bit of your props.
is there a way to force it to not do that?
I don't think so, no. Honestly, this is sort of a downside of the 'magic' of destruring a discriminated union here.
You could put a discriminated union in your state object, I guess.
would that prevent it from mushing everything together?
You can just set the id prop to type never
It's not really that it's 'mushing things together', it's that the discrimination only happens inside a edit === true check:
if(union.edit === true) {
// the union is an edit kind here
}
or undefined
maybe better
anything that just causes it to be a discriminating prop
yep so adding a __type discriminator worked a treat
but now it pings the param of saveEntity as (NewEntity & Entity)
lol
adding a type discrim to Entity as well resulted in a never
Yeah, because it doesn't know what save function you're working with.
because i'm not in edit
Technically, TS is right to be skeptical here.
Your saveEntity is passed as a prop for a specific type, but your state is mutable and can be either kind of entity.
so annoying that i could instantly solve this with a generic
I'm actually not sure about that - I actually think generics might run into similar problems.
yeah i think you might be right
this is what i get for trying to write reusable code
it feeeeeels like there should be a way to achieve this
correspondence problem again
failing to see the correspondence between edit true -> Entity, edit false -> NewEntity
The compiler
It's a compiler limitation that arises from early resolution of property access on unions
You destructure saveEdit so it tries to unionize the prop on both versions of the object
and due to contravariance you get an intersection in the parameters
I just want to have a component form that calls a save function
hmmmmmmmm
if
new entity is a subtype of entity
can i just say its a newentity
and pass an entity in?
You can just put an explicit union on the param of saveEdit
but of course you lose some of the type safety you're trying to express
Here is a thing you can do:
type EntityState =
| { edit: true; entity: Entity; saveEntity: (entity: Entity) => Promise<Entity> }
| { edit: false; entity: NewEntity; saveEntity: (entity: NewEntity) => Promise<Entity> };
const TestEntity = ({
//...
}: EditEntityProps | NewEntityProps) => {
const [state, setState] = useState<EntityState>(
edit
? { edit, entity: existing, saveEntity }
: { edit, entity: existing ?? makeNewEntity(type), saveEntity }
);
const saveFunc = useCallback(() => {
const save = state.edit ? state.saveEntity(state.entity) : state.saveEntity(state.entity);
save.then(() => {
onSave();
});
}, [state, onSave]);
This keeps the discriminated union in the state so that you can know that the saveEntity function keeps the same type as entity even if the state is mutated later.
const save = state.edit ? state.saveEntity(state.entity) : state.saveEntity(state.entity);
see this
lunacy to me
Yeah, that's a workaround for the correspondence problem.
that is the same code
that was what i was trying to avoid lol
i originally had two functions
Ah, well, then just cast and move on?
and i was trying to combine them
okay this was super enlightening on the limitations of TS
or not limitations
but i thought it could do with some things
thank you both for all your help
!:*correspodence-problem
`!retsam19:correspondence-problem`:
There's a particular pattern that is safe but hard for the Typescript compiler to handle, which I call the "correspondence problem":
const functionsWithArguments = [
{ func: (arg: string) => {}, arg: "foo" },
{ func: (arg: number) => {}, arg: 0 },
];
for (const { func, arg } of functionsWithArguments) {
func(arg);
// ^^^
// Argument of type 'string | number' is not assignable to parameter of type 'never'.
// Type 'string' is not assignable to type 'never'.
}
The problem is that func is typed as (x: string) => void | (x: number) => void and arg is string | number, but the compiler can't prove that they "correspond": that, for example, arg is only a string when func accepts strings.
As far as the type are concerned, arg could be number, and func could be (arg: string) => void, and that would be a type-error. It's easy for us to see that that won't happen, but that requires understanding the program at a higher-level than the level the compiler operates.
Depending on the specifics there's sometimes clever fixes, but usually I recommend using a type assertion and ignoring the issue:
func(arg as never);
This is a big part of what's going on with the discriminated union bit, specifically.
I'd describe TS's general complaint that your state is mutable but your saveEntity prop is not and can only handle one type.
stoopid compiler lrn2correspond fkn noob
but yeah i do see the issue
i spent like 4h bashing my head on this at work today lmao
glad i got this assistance though, its sure to happen again
we've got a fairly mature react codebase that we are converting to TS
most of what has been converted to date is generic reusables because i figured that would give the best bang for buck
and everything being generics meant stuff like this didn't really come up
Yeah, overall I actually think discriminated unions are a nicer pattern, but you'd need to preserve the union bit of it throughout the state here.
Yeah, wouldn't use runtime values; conditional types are pretty niche in type-safe code.
const Component ...
type EntityType = edit extends true ? Entity : NewEntity;
yeah thats an outrageous thing to do lol
oh well, glad to know how to soldier on
thanks again
Maybe you could extract a small generic utility inside the overall union based component, actually.
oh wow yeah
i could use edit in a proper discriminator function
getEntity(edit: boolean, entity: Entity | undefined) => edit ? entity as Entity : makeNewEntity()
would that give me a single consistent type?
nope
lol
i saw an XOR utility class
i wonder if that would be applicable here
but no, correspondence is still the issue
alright i need to go to bed now its late af here
one more thank you