Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript narrowing type with generic error

Can someone help me understand what is going on here: TS playground

basically I have a store that has an exec method, I want to narrow the type of exec param for a sub process But there seems to be an error with the store type being a generic type

type Param<Options> = {
  [K in keyof Options]: Readonly<{
    id: K,
    options: Options[K],
  }>
}[keyof Options];

interface Store<Options> {
    exec: (nextState: Param<Options>) => void
}

type ParentOptions = {
    'a': { a: string },
} & SubOptions

type SubOptions = {
    'b': { b: number },
}

function test(
    parentFlowExec: (nextState: Param<ParentOptions>) => void,
    subFlowExec: (nextState: Param<SubOptions>) => void,
    
    parentNonGeneric: { exec: (nextState: Param<ParentOptions>) => void },
    subNonGeneric: { exec: (nextState: Param<SubOptions>) => void },
    
    parentFlow: Store<ParentOptions>,
    subFlow: Store<SubOptions>,
    
) {
    parentFlowExec = subFlowExec; // error: ok
    subFlowExec = parentFlowExec; // passed

    parentNonGeneric = subNonGeneric; // error: ok
    subNonGeneric = parentNonGeneric; // passed

    parentFlow = subFlow; // error: ok
    subFlow = parentFlow; // error ??

    // I plan to use it like this
    subProcess(parentFlow);
}

function subProcess(flowStore: Store<SubOptions>) {
    flowStore.exec({ id: 'a', options: { a: 'a' } }); // can't call with 'a'
    flowStore.exec({ id: 'b', options: { b: 3 } }); // ok
}

Update: I moved the Param out and have it working but still don't understand why nested them doesn't work

interface Store<Options> {
    exec: (nextState: Options) => void
}
// parent2: Store2<Param<ParentOptions>>,
// sub2: Store2<Param<SubOptions>>,
like image 758
Tubc Avatar asked May 03 '26 22:05

Tubc


1 Answers

To answer your question, first, let's quickly recap what different "variance" means. In the following table, I am using definitions from Microsoft's .NET documentation (except for bivariance that's not in the docs) since I find them the easiest to grasp:

Variance Meaning Allowed substitutions
Bivariance Covariance and Contravariance at the same time Supertype -> Subtype, Subtype -> Supertype
Covariance Enables you to use a more derived type than originally specified Supertype -> Subtype
Contravariance Enables you to use a less derived type than originally specified Subtype -> Supertype
Invariance Means that you can use only the type originally specified none

Let's check which one of your types is a supertype and which is a subtype:

type T1 = SubOptions extends ParentOptions ? true : false; // false
type T2 = ParentOptions extends SubOptions ? true : false; // true

It follows that the PartentOptions is a subtype of SubOptions, while the latter is its supertype. What does it tell us? It tells us that when you annotate subFlow as Store<SubOptions> and then try to assign parentFlow to it (annotated as Store<ParentOptions>), you are trying to assign a subtype where a supertype is expected.

If we refer to the variance table, we will see that this requires covariance, but as you get an error, this means we are dealing with either contravariance or invariance. Now, when you assign subFlow to parentFlow, you are assigning a supertype where a subtype is expected.

The above also results in an error, meaning the assignment here is actually invariant, and @captain-yossarian's comment is correct:

I believe that it is because subFlow and parentFlow are invariant to each other.

This behavior, however, is a design limitation of TypeScript (see Anders Hejlsberg's comment on the related issue) sacrificing some flexibility for soundness (remove the [keyof Options] indexing, and you will see that the contravariant assignment becomes possible).

As for your solution, due to how variance analysis works, when you move the Params outwards, the parameter types become covariant (as T[keyof T] is not aliased here. Note that when reduced to the bare structure, the Param type is exactly that: type Param<Options> = Options[keyof Options], only mapped1).

Take a look at a simplified example0 of your solution:

type Param<Options> = {
  [K in keyof Options]: Readonly<{
    id: K,
    options: Options[K],
  }>
}[keyof Options];

interface Store<Options> {
    exec: (nextState: Options) => void
}

type SuperOptions = { 'b': { b: number } }
type SubOptions = { 'a': { a: string } } & SuperOptions

const test1 = (subtype: Store<Param<SubOptions>>) => subProcess1(subtype); // OK, Subtype -> Supertype, covariance
const test2 = (supertype: Store<Param<SuperOptions>>) => subProcess2(supertype); // error, Supertype -> Subtype, contravariance

const subProcess1 = (supertype: Store<Param<SuperOptions>>) => supertype.exec({ id: 'b', options: { b: 3 } }); // ok
const subProcess2 = (subtype: Store<Param<SubOptions>>) => subtype.exec({ id: 'b', options: { b: 3 } }); // ok

Playground


0 Your naming choice slightly adds confusion to an already tough problem: a subtype is called ParentOptions and supertype SubOptions, while the relationship between them is the opposite, so I named them SubOptions and SuperOptions accordingly to make things clearer.

1 From the discussion in comments, it must be noted that while the relationship between Store<Param<SubOptions>> and Store<Param<SuperOptions>> in the solution is covariant, T[keyof T] here is contravariant (see Anders's comment - the SuperOptions supertype has fewer properties than the SubOptions subtype, and there is no discriminant).

like image 129
Oleg Valter is with Ukraine Avatar answered May 06 '26 12:05

Oleg Valter is with Ukraine