#TS compiler swallowing Optional type

135 messages · Page 1 of 1 (latest)

scenic ingot
wispy wedge
#

But... You are supplying a default value with the nullish coalescing operator

visual axle
#

@scenic ingot When one type is a subtype of another, TS will infer the return type to be the common supertype, not a union.

scenic ingot
#

so why does test2 show as NewEntity | undefined?

#

it's like it's discarding Entity even though when edit is true, existing is Entity

visual axle
#

Because as far as TS knows, edit can be true and existing can be undefined.

scenic ingot
#

can i not use edit as a type discriminator for the props?

visual axle
#

I think the issue is the default value.

scenic ingot
#

on edit?

visual axle
#

If you get rid of the = false in edit = false, yeah.

scenic ingot
#

just updated

#

both test and test2 have a return type of NewEntity

visual axle
#

Yeah, that's still the first thing I said: TS infers the common supertype.

unkempt foxBOT
#
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;
scenic ingot
#

wow okay

visual axle
#

You can add an explicit type annotation (like f2 above) to force it to use the union return type.

scenic ingot
#

that's less than useful for a react state lol

visual axle
#

Depends on what you're doing with it.

scenic ingot
#

well thats the issue

visual axle
#

Yeah, so you're going to want to put the annotation on it.

scenic ingot
#

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

visual axle
#

You add an annotation.

scenic ingot
#

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

visual axle
#
const test = (): NewEntity | Entity => existing ?? makeNewEntity(type);
const test2 = (): NewEntity | Entity => (edit ? existing : makeNewEntity(type));
scenic ingot
#

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

visual axle
#

You're talking about saveFunc now?

scenic ingot
#

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

visual axle
#

Yeah, your state loses the 'discriminated union' bit of your props.

scenic ingot
#

is there a way to force it to not do that?

visual axle
#

I don't think so, no. Honestly, this is sort of a downside of the 'magic' of destruring a discriminated union here.

scenic ingot
#

so what i could do

#

is use a branded type for the new one?

visual axle
#

You could put a discriminated union in your state object, I guess.

scenic ingot
#

would that prevent it from mushing everything together?

wispy wedge
#

You can just set the id prop to type never

visual axle
#

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
}
wispy wedge
#

or undefined

#

maybe better

#

anything that just causes it to be a discriminating prop

scenic ingot
#

yep so adding a __type discriminator worked a treat

unkempt foxBOT
#
angryzor#9490

Preview:ts ... interface NewEntity { id: undef ...

scenic ingot
#

but now it pings the param of saveEntity as (NewEntity & Entity)

#

lol

#

adding a type discrim to Entity as well resulted in a never

visual axle
#

Yeah, because it doesn't know what save function you're working with.

scenic ingot
#

because i'm not in edit

visual axle
#

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.

scenic ingot
#

so annoying that i could instantly solve this with a generic

visual axle
#

I'm actually not sure about that - I actually think generics might run into similar problems.

scenic ingot
#

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

wispy wedge
#

correspondence problem again

#

failing to see the correspondence between edit true -> Entity, edit false -> NewEntity

scenic ingot
#

wait could i add a type discrim to the props?

#

me or the compiler?

wispy wedge
#

The compiler

#

It's a compiler limitation that arises from early resolution of property access on unions

scenic ingot
#

bummer

#

that's actually really frustrating

wispy wedge
#

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

scenic ingot
#

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?

wispy wedge
#

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

visual axle
#

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.

scenic ingot
#

const save = state.edit ? state.saveEntity(state.entity) : state.saveEntity(state.entity);

#

see this

#

lunacy to me

visual axle
#

Yeah, that's a workaround for the correspondence problem.

scenic ingot
#

that is the same code

#

that was what i was trying to avoid lol

#

i originally had two functions

visual axle
#

Ah, well, then just cast and move on?

scenic ingot
#

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

visual axle
#

!:*correspodence-problem

unkempt foxBOT
#
Retsam19#2505
`!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);
visual axle
#

This is a big part of what's going on with the discriminated union bit, specifically.

scenic ingot
#

riiiiiight

#

that explains it really well

visual axle
#

I'd describe TS's general complaint that your state is mutable but your saveEntity prop is not and can only handle one type.

scenic ingot
#

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

visual axle
#

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.

scenic ingot
#

is there a potential for a conditional type?

#

or can that not use runtime values

visual axle
#

Yeah, wouldn't use runtime values; conditional types are pretty niche in type-safe code.

scenic ingot
#
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

visual axle
#

Maybe you could extract a small generic utility inside the overall union based component, actually.

scenic ingot
#

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