#Can someone explain why this switch statement isn't narrowing my generic?

35 messages · Page 1 of 1 (latest)

humble sinew
#

Here is my code. Why am I getting this error?

#
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."

fiery breachBOT
icy vector
#

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

humble sinew
icy vector
#

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

humble sinew
#

(I'm wondering if it has something to do with generics being narrowed on a separate pass before the compiler checks the function body.)

icy vector
#

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;
  }
};
icy vector
#

This way you define an "internal" and an "external" signature separately. callers still have the full type safety

humble sinew
humble sinew
humble sinew
#

!helper

humble sinew
icy vector
#

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

icy vector
#

If you want to know more about that:

fiery breachBOT
#
angryzor#9490

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 {
    ...```
humble sinew
#

thanks @icy vector