枚举映射附加约束的条件类型

Conditional type for additional constraint on enum mapping

在我的项目中,我有两个枚举 SourceEnumTargetEnum。对于这两个枚举,都存在一个函数,它使用一些依赖于枚举值的参数来调用。预期参数的确切类型由两个类型映射 SourceParamsTargetParams.

定义
enum SourceEnum {
  SOURCE_A = 'SOURCE_A',
  SOURCE_B = 'SOURCE_B'
}

enum TargetEnum {
  TARGET_A = 'TARGET_A',
  TARGET_B = 'TARGET_B',
}

interface SourceParams {
  [SourceEnum.SOURCE_A]: { paramA: string };
  [SourceEnum.SOURCE_B]: { paramB: number };
}

interface TargetParams {
  [TargetEnum.TARGET_A]: { paramA: string };
  [TargetEnum.TARGET_B]: { paramB: number };
}

function sourceFn<S extends SourceEnum>(source: S, params: SourceParams[S]) { /* ... */ }

function targetFn<T extends TargetEnum>(target: T, params: TargetParams[T]) { /* ... */ }

我有一个映射,其中包含一个函数来评估每个源值的目标值,我想做的是,确保用于调用 sourceFn(x, params)params 对象也能正常工作来电 targetFn(mapping[x](), params)。为此,我创建了这种类型:

type ConstrainedMapping = {
  [K in SourceEnum]: <T extends TargetEnum>() => (SourceParams[K] extends TargetParams[T] ? T : never) 
};

const mapping: ConstrainedMapping = {
  [SourceEnum.SOURCE_A]: () => TargetEnum.TARGET_A;
  // ...
}

但是像上面那样定义 mapping 会出现以下错误:

Type 'TargetEnum.TARGET_A' is not assignable to type '{ paramA: string; } extends TargetParams[T] ? T : never'.

我的打字看起来很清楚,所以我不太明白这里的问题是什么。我想打字稿在某些时候无法缩小确切的枚举值。

有办法实现吗?我目前正在使用 Typescript 4.2,但我也在 4.3 和 4.4-beta 上尝试过,并且都显示出相同的行为。非常感谢 4.2 中的解决方案,但未来版本中的解决方案对我来说也很好。

所以你在这里期望的是让 Typescript 看到 <T extends TargetEnum>(): SourceParams[K] extends TargetParams[T] ? T : never;,然后将 T 的所有可能值分配到这个条件中,并创建一个为真值的并集。

问题是,Typescript 没有这样做。它将 T 视为未知,并且除了将 { paramA: string; } 替换为 SourceParams[K] 之外,不再对该表达式求值。您要查找的分布仅出现在 Distributive Conditional Types 中。所以我们必须重写你的ConstrainedMapping来使用一个。

分布式条件类型是一种条件类型别名,其中 none 个参数受到约束。所以首先,我们需要一个实际的 type 声明这个 return 值,我们不能把它放在更大的 ConstrainedMapping 声明中。 (是的,这很奇怪——如果将类型提取到它自己的别名中,类型的行为会有所不同,这是我对 Typescript 设计的最大问题之一。)像这样:

type TargetWithMatchingParams<S, T> =
  S extends SourceEnum
    ? T extends TargetEnum
      ? SourceParams[S] extends TargetParams[T]
        ? T
        : never
      : never
    : never;

我们无法限制 ST,因此我们必须为此在模板中使用更多条件。 (是的,这也很奇怪;像这样改变行为是相当违反直觉的。)我们也不能简单地将整个 TargetEnum 硬编码在这里,即使这最终是我们想要的——分布有跨越类型别名的无约束参数。

当我们完成那个后,我们就可以在ConstrainedMapping中使用它了:

type ConstrainedMapping = {
  [S in SourceEnum]: () => TargetWithMatchingParams<S, TargetEnum>;
};

请注意,该函数不再是通用的——那是你问题的一部分——我们通过将 TargetEnum 传递到 TargetWithMatchingParams 来实现你正在寻找的分布。

顺便说一下,如果您的真实案例像您的示例一样是静态的,请从 ConstrainedMapping 的定义中删除 () => 并使用 mapping[x]“调用”mapping而不是 mapping[x]() 会稍微提高性能,并且可以说更容易阅读。

最后,这里有一些限制需要注意。

  1. extends SourceEnum 不起作用的通用变量上调用 mapping。也就是说,targetFn(mapping[SourceEnum.SOURCE_A](), { paramA: 'foo' }) 可以工作,但是 Typescript 会遇到这样的问题:

    function bar<S extends SourceEnum>(src: S, params: SourceParams[S]) {
      targetFn(mapping[src](), params);
                               ^^^^^^
    //                         Argument of type 'SourceParams[S]'
    //                           is not assignable to parameter of type
    //                           'TargetParams[ConstrainedMapping[S]]'.
    }
    

    也就是说,Typescript 不够聪明,无法真正理解 SourceParamsTargetParams 之间的关系,并且无法识别任何有效的 SourceParams 值将始终匹配相应的 TargetParams.

  2. 在我们接受源参数联合和源参数联合的情况下,基本上是上述的非通用版本,Typescript 允许不安全的值。考虑:

    function baz(src: SourceEnum, params: SourceParams[SourceEnum]) {
      targetFn(mapping(src), params);
    }
    baz(SourceEnum.SOURCE_A, { paramB: 42 });
    

    这不会导致任何错误,即使我们有 SOURCE_AparamB,这是一个无效的组合。不过,归根结底,这只是对 Typescript 联合工作方式的限制——问题出在 baz 的定义或 SourceParams/TargetParams 的定义中,并且会完全相同如果 baz 调用了 sourceFnmapping 根本没有进入它。

这里太长,没有阅读的版本只是确保你用特定的枚举类型调用 sourceFntargetFn,而不是整个枚举。 Typescript 不会跟踪单独变量之间的关系,因此它不会检查您的未知 SourceEnum 和未知 SourceParams[SourceEnum] 是否真的在一起,并且 mapping 的定义不会解决那个问题。