#TypeScript invariance and structural type checking on nested generics strange behavior

28 messages · Page 1 of 1 (latest)

eternal jay
#

To my understanding typescript should just try to check if NestedGeneric<'A'> is assignable to constraint NestedGeneric<Variable>

While doing it, it should check if type parameter T in NestedType is invariant or covariant (correct me if I am messing up the terminology). If the NestedType is a distributive conditional type over T, the T is covariant, otherwise, by default, it is invariant. If it is invariant, and the given type (of T) is not exactly the same as the constraint, ts throws an error.

Otherwise, it goes deeper.

Therefore

type NestedGenericHandlerA = NestedGenericHandler<NestedGeneric<'A'>>;

raises TS error, and the "expected solution" works

type NestedGenericDistributive<T extends Variable> = T extends any
  ? { generic: Generic<T> }
  : null;

this is covariant case and everything is ok.

So, the first question is - is the above explanation correct?


Now, if it is correct, then what is going on in the code that goes below the "expected solution"?

#

Lines that didn't fit the snippet


// -----------------------------------------------------------------------------
// More bloody magic...
// Replacing `Generic` with:
// -----------------------------------------------------------------------------

// doesn't help
type GenericDistributiveBad<T extends Variable> = T extends any
  ? Extract<OptionAny, { value: T }>
  : null;

// even though
type TestBad = GenericDistributiveBad<Variable>;
//   ^? | Extract<{value: "A"} | {value: "B"}, {value: "A"}>
//      | Extract<{value: "A"} | {value: "B"}, {value: "B"}>

// But this does help
type GenericDistributiveGood<T extends Variable> =
  | (T extends 'A' ? { value: T } : never)
  | (T extends 'B' ? { value: T } : never);

// Is this different to `TestBad`?
type TestGood = GenericDistributiveGood<Variable>;
//   ^? {value: "A"} | {value: "B"}

// afterall, `Extract` is
//    `type Extract<T, U> = T extends U ? T : never;`
// which means that each type in the TestBad union must go through the following:
//  Extract<{value: "A"} | {value: "B"}, {value: "A"}>
//    is equal to
//      | ({value: "A"} extends {value: "A"} ? {value: "A"} : never)
//      | ({value: "B"} extends {value: "A"} ? {value: "B"} : never)
// same for Extract<{value: "A"} | {value: "B"}, {value: "B"}>
//  ...
// Result is: {value: "A"} | {value: "B"}

// -----------------------------------------------------------------------------
// Oh, and BTW
// -----------------------------------------------------------------------------

type TestGeneric = Generic<'A' | 'B'>;
//   ^? {value: "A"} | {value: "B"}

// :)

hot mica
#

@eternal jay Please don't repost your question across several channels - if it's been a few hours we have a !helper command which can ping people to take a look at the question.

eternal jay
#

!help

marble canopyBOT
#
TypeScript Community
Bot Usage

Hello aleksandr_space_architects! Here is a list of all commands in me! To get detailed description on any specific command, do help <command>

**Help System Commands:**

helper ► Ping the @Helper role from a help post
resolved ► Mark a post as resolved
reopen ► Reopen a resolved post

**Misc Commands:**

ping ► See if the bot is alive
playground ► Shorten a TypeScript playground link
help ► Sends what you're looking at right now
handbook ► Search the TypeScript Handbook

**Reputation Commands:**

rep ► Give a different user some reputation points
history ► View a user's reputation history
leaderboard ► See who has the most reputation

**Snippet Commands:**

listSnippets ► List snippets matching an optional filter
snip ► Create or edit a snippet
deleteSnip ► Delete a snippet you own

**Twoslash Commands:**

twoslash ► Run twoslash on the latest codeblock, optionally returning the quick infos of specified symbols. You can use ts@4.8.3 or ts@next to run a specific version.

delicate kayak
#

the error you're referring to isn't related to variance, it's just that { x: A | B } is not equivalent to { x: A } | { x: B }.

eternal jay
# delicate kayak the error you're referring to isn't related to variance, it's just that `{ x: A ...

Thanks for taking the time to answer this question, but It is not a trivial situation going on here.
Please read the whole code snippet carefully. Your points about variance are indeed correct, but they don't help here.
And "it's just that { x: A | B } is not equivalent to { x: A } | { x: B }." doesn't help either.
I'd greately appreciate if you can give more elaborate answer. At least, where this A|B is happening and why it is working differently in different contexts illustrated in the snippet

#

!playground

mystic relic
#

my immediate guess is that it boils down to something like this, since it seems like it makes a difference whether you do type Z = A<B<C>> vs type D = B<C>; type Z = A<D>

#

but the playground you shared has a lot of code to get through. if you can reduce it down to a minimal repro i could take a closer look

eternal jay
#

@mystic relic thank you for your response. I'll go through the issue you've referenced. At a first glance it is not obvious how it related to my case, but it might be - I need to spend some time on it. Will get back to you as I do that.

Regarding the "small repro" - this is as small as I can get. The whole point is that things that must be equal, in fact work differently and it is completely counterintuitive.
I believe there must be one exact answer to all weird behaviors in this snippet - and in this anwser I suppose there must be an exact alogorithm of typescript type checking applied in such scenarios.
I even just a link to the right place in TS source code would do.

marble canopyBOT
#
mkantor#0

Preview:```ts
type Generic<T> = Extract<'A' | 'B', T>;

type NestedGeneric<T> = { generic: Generic<T> };

type NestedGenericHandler<T extends NestedGeneric<'A' | 'B'>> = unknown;

// error
type NestedGenericHandlerA = NestedGenericHandler<NestedGeneric<'A'>>;

// -----------------------------------------------------------------------------
...```

eternal jay
#

I would say it is incomplete. It is only a part of the problem. All other cases are also important.
Like why this helps

type GenericDistributiveGood<T extends Variable> =
  | (T extends 'A' ? { value: T } : never)
  | (T extends 'B' ? { value: T } : never);

and this doesn't

type GenericDistributiveBad<T extends Variable> = T extends any
  ? Extract<OptionAny, { value: T }>
  : null;

and why (why exactly ) this is fine

type GenericHandler<T extends Generic<Variable>> = { handle(input: T): void };
// no error
type GenericHandlerA = GenericHandler<Generic<'A'>>;

and this is not:

type NestedGeneric<T extends Variable> = { generic: Generic<T> };

type NestedGenericHandler<T extends NestedGeneric<Variable>> = {
  handle(input: T): void;
};
// error
type NestedGenericHandlerA = NestedGenericHandler<NestedGeneric<'A'>>;

I see that you want to break it down to separate smaller problems, but I believe there is a single answer to all these questions. But they all together help to point out the direction.

And, of course, the cases with the type aliases, that you've added to your "short" version

mystic relic
#

my suspicion is that they all boil down to something like "the type checker takes shortcuts when comparing two instantiations of the same generic type" (where "same" means they have the same name/internal type ID). rather than doing a complete structural comparison it's just checking assignability of the type parameters to eachother using their (inferred) variance, but there's some bug there causing all this trouble. that would explain why making a differently-named-but-structurally-identical type helps—it forces the full structural check instead

#

but i am not a maintainer and have only barely poked around in the typescript implementation, so this is not a well-informed guess and i can't point to specific code

eternal jay
#

That is what I thought too, but then why, if I change the implementation of the deepest nested generic in a particular way, it fixes the entire thing?

I bet there is a set of rules for when TS does the full structural checking and when it falls back to do checks of variance and other things like that; and in what cases it does the shortcuts and how it does it exactly...

mystic relic
#

you could try pulling the typescript repo and adding a test that triggers this behavior, then stepping through the checker? that's probably how i'd start if i didn't have access to someone who can walk me through the compiler internals

eternal jay
#

It is just not documented. However how can you confidently design your types when you're writing a highly generic and abstract library?

eternal jay
mystic relic
#

i feel like i've seen maintainers link to other resources along the same lines, but i can't immediately find them. you could pop over to #compiler-internals-and-api and ask there

eternal jay
#

Thanks for the advice. I joined TS Discord a few days ago and am not used to it yet.