#Can someone explain why this switch statement isn't narrowing my generic?
35 messages · Page 1 of 1 (latest)
export const versionOrder = [1, 2, 3] as const;
export type AnyVersion = typeof versionOrder[number];
export type VersionVariableMap = {
1: { x: number };
2: { y: number };
3: { z: number };
};
export type Variable<T extends AnyVersion> = VersionVariableMap[T];
export type MigrateTo<
MNextVersion extends AnyVersion,
MCurrentVersion extends Previous<MNextVersion, typeof versionOrder> = Previous<
MNextVersion,
typeof versionOrder
>
> = (save: SaveData<MCurrentVersion>) => Variable<MNextVersion>[];
declare const migrateTo2: MigrateTo<2>;
export const getMigrationScriptFromCurrent = <
GetCurrentVersion extends AnyVersion,
GetNextVersion extends Next<GetCurrentVersion, typeof versionOrder>
>(
fromVersion: GetCurrentVersion
): MigrateTo<GetNextVersion> => {
switch (fromVersion) {
case 1:
return migrateTo2;
/*
Type 'MigrateTo<2, 1>' is not assignable to type 'MigrateTo<GetNextVersion, 2 extends GetNextVersion ? 1 : 3 extends GetNextVersion ? 2 : never>'.
Type '2' is not assignable to type 'GetNextVersion'.
'2' is assignable to the constraint of type 'GetNextVersion', but 'GetNextVersion' could be instantiated with a different subtype of constraint '2 | 3'.ts(2322)
*/
case 2:
return 1 as any; // TODO
case 3:
return 1 as any; // TODO
default:
const x: never = fromVersion;
return 1 as any;
}
};
export interface SaveData<T extends AnyVersion = AnyVersion> {
version?: T;
variables: Variable<T>[];
}
export type Next<
Item,
List extends readonly unknown[]
> = List extends readonly [infer First, infer Second, ...infer Rest]
? First extends Item
? Second
: Next<Item, [Second, ...Rest]>
: never;
export type Previous<
Item,
List extends readonly unknown[]
> = List extends readonly [infer First, infer Second, ...infer Rest]
? Second extends Item
? First
: Previous<Item, [Second, ...Rest]>
: never;
Here's what I'm expecting: when I am inside case 1:, fromVersion is known to be 1. Therefore the type system knows that I just need to return a function that migrates from v1 to v2.
I might be misunderstanding this error message, but to me it seems to be saying, "Hey, you haven't considered if fromVersion is 2 or 3 inside the case 1: so this is an error."
Yeah this is one of the most common questions I see in this server
the problem is that NextVersion could be manually set to 3 for instance
which one?
Do you mean GetNextVersion or MNextVersion? Assuming the former, how can you do that when it has to extend Next<GetCurrentVersion, typeof versionOrder>?
Trying out some stuff, I might have spoke too soon. It looks very similar to something that comes up frequently here but your constraints are quite strict
Okay thank you I appreciate it a lot.
(I'm wondering if it has something to do with generics being narrowed on a separate pass before the compiler checks the function body.)
It's a combination of various factors and it comes down to "TS isn't smart enough for this"
A big contributor is that the compiler can't completely understand relations between types
A second contributor is that the compiler tries to build 1 single typing for all possible invocations, i.e. it does not have multiple different sets of types for the variables in a function depending on what the types of the generic parameters are
It tries to derive a single type and uses unions to expand the type of a variable to cover multiple types
in the case of getMigrationScriptFromCurrent what I suspect is happening because that's what's always the issue with this error is:
It sees that CurrentVersion is constrained to type 1 | 2 | 3
and sees that NextVersion is constrained to type Next<CurrentVersion, typeof versionOrder>
Which would be 2 | 3 (and this is where your situation is not like most because your implementation of Next actually only looks at the lowest version and returns 2. I don't know why the compiler arrives at 2 | 3 (see error message), maybe it just executes the type function for every union element of CurrentVersion separately.)
And then it tries to check if the function would be valid for those maximum constraints
Now it descends into the narrowed switch statement, it doesn't realize NextVersion is dependent on CurrentVersion and it goes "oh wait this can't be right this function could potentially be called as getMigrationScriptFromCurrent<1, 3>(1) and then this return value would be invalid"
There's a simple workaround but you lose some type safety inside the function
which is to (ab)use an overload:
export function getMigrationScriptFromCurrent<
CurrentVersion extends AnyVersion,
NextVersion extends Next<CurrentVersion, typeof versionOrder>
>(
fromVersion: CurrentVersion
): MigrateTo<NextVersion>
export function getMigrationScriptFromCurrent(
fromVersion: AnyVersion
): MigrateTo<AnyVersion> {
switch (fromVersion) {
case 1:
return migrateTo2;
case 2:
return 1 as any; // TODO
case 3:
return 1 as any; // TODO
default:
const x: never = fromVersion;
return 1 as any;
}
};
This way you define an "internal" and an "external" signature separately. callers still have the full type safety
Thank you for the explanation and the help. I don't really understand this or the next sentence, but I think I get the rest.
I didn't know you could overload with the single signature. In this case I'm glad you can, but why is that useful otherwise?
!helper
Requesting a helper for answering this last question
No idea 😄
I would guess it's just why wouldn't it work
You'd have to special case to not accept it
That overload syntax is actually some sugar that avoid a typecast for a function type that could be safely cast
If you want to know more about that:
Preview:```ts
/*
- Where overload syntax doesn't make a difference: when only parameters change
*/
// ✔️ Using overload - valid code, typechecks
function foo(a: string): void
function foo(a: number, v: boolean): void
function foo(a: string | number, v?: boolean): void {
...```
thanks @icy vector