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>>,
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).
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With