鉴于我在这里的映射,为什么TypeScrip不能自动推断州的类型?

我的FormIdToFormValue不应该足以让Tyescript知道发生了什么吗?

export enum FORM_ID {
  STEP_1 = 'step1',
  STEP_2 = 'step2',
  STEP_3 = 'step3',
}

export type FormIdToFormValues = {
  [FORM_ID.STEP_1]: Step1FormValues;
  [FORM_ID.STEP_2]: Step2FormValues;
  [FORM_ID.STEP_3]: Step3FormValues;
};

type Step1FormValues = {
    a: string
}
type Step2FormValues = {
    b: string
}

type Step3FormValues = {
    c: string
}


// Define the type of the state parameter
type StateType<F extends FORM_ID> = FormIdToFormValues[F];

export const getNextRoute = <F extends FORM_ID>(formId: F, state: StateType<F>) => {
  switch (formId) {
    case FORM_ID.STEP_1:
      // Error happens here -----
      if (state?.a) {
        return '/step-3';
      }
      return '/step-2';
    default:
      return '/';
  }
};

详细的错误是:

Property 'a' does not exist on type 'Step1FormValues | Step2FormValues | Step3FormValues'.
  Property 'a' does not exist on type 'Step2FormValues'.

推荐答案

不幸的是,在TypeScrip中,generic个类型参数目前不受control flow analysis的影响.所以在case号街区内

const getNextRoute = <F extends FORM_ID>(formId: F, state: StateType<F>) => {
  switch (formId) {
    case FORM_ID.STEP_1:
      if (state?.a) { // error
        return '/step-3';
      }
      return '/step-2';
    default:
      return '/';
  }
};

formId value的类型已经从通用类型参数F缩窄到F & FORM_ID.STEP_1,类型参数F本身不受影响. 因此,编译器无法验证类型StateType<F>state可以像StateType<FORM_ID.STEP_1>一样处理,并且它失败了.


这似乎是一个奇怪的限制,但事实证明很难正确实现所需的行为.让我们想象一下最简单的情况;一个函数,如

function f<T extends string | number>(a: T, b: T) { 
  return (typeof a === "string") ? b.toUpperCase() : b.toFixed() // error!
  //                                 ~~~~~~~~~~~ <---> ~~~~~~~
}

编译器抱怨b仍然是string | number,因此不知道有toUpperCasetoFixed. 很明显,判断a也应该影响b,对吗?

不对.T不一定是string的子类型或number的子类型;它也可以是union type.就像这样:

f(Math.random() < 0.99 ? "" : 0, 0); // okay

第一个参数是"" | 0类型的,因此T被推断为"" | 0.这是可以接受的,因为"" | 0string | number的子类型.然后判断第二个参数实参,果然,0也是类型"" | 0的有效值.如果T"" | 0,那么a很可能是string,bnumber,反之亦然.实际上,此调用有99%的机会在运行时调用0上的toUpperCase()并导致运行时错误.哎呀.

因此,任何将泛型类型参数与控制流分析结合在一起的更改都需要仔细考虑.有各种开放的特性请求来做到这一点;与您的示例代码最相关的是microsoft/TypeScript#33014microsoft/TypeScript#27808.如果实现了这两种方法中的一种或两种,您的代码可能开始按原样工作.但就目前而言,它不是语言的一部分,您需要解决它.


到目前为止,最简单的解决方法是使用type assertion:

const getNextRoute = <F extends FORM_ID>
  (
    formId: F, state: StateType<F>
  ) => {
  switch (formId) {
    case FORM_ID.STEP_1:
      if ((state as Step1FormValues)?.a) { // assert
        return '/step-3';
      }
      return '/step-2';
    default:
      return '/';
  }
};

在这里,我们不再试图让编译器遵循逻辑,而只是tell地让编译器了解正在发生的事情.这意味着我们必须更加小心,因为编译器可能不会注意到我们的断言是否不正确.

如果您do希望编译器遵循您的逻辑,那么您将需要重构以停止使用泛型函数,或者停止使用控制流分析.


一种方法是放弃泛型,以便可以使用控制流分析. 只有当你的函数实际上不是泛型的时候,这才是可行的,比如如果返回类型不依赖于输入. 由于无论如何输出都是string,因此您应该能够重写调用签名,使其不是通用的. 这里有一种方法可以做到这一点:

type GetNextRouteParams =
  { [F in FORM_ID]: [formId: F, state: StateType<F>] }[FORM_ID];

/* type GetNextRouteParams = 
     [formId: FORM_ID.STEP_1, state: Step1FormValues] | 
    [formId: FORM_ID.STEP_2, state: Step2FormValues] | 
    [formId: FORM_ID.STEP_3, state: Step3FormValues]; */

const getNextRoute: (...args: GetNextRouteParams) => string =
  (formId, state) => {
    switch (formId) {
      case FORM_ID.STEP_1:
        if (state?.a) { // okay
          return '/step-3';
        }
        return '/step-2';
      default:
        return '/';
    }
  };

如果判断GetNextRouteParams的类型,您会发现它是tuple typesdiscriminated union,与您希望getNextRoute()接受的参数集相对应.然后,我们将元组并集的rest parameter赋给getNextRoute‘S调用签名,突然,实现工作了.

编译器知道它可以将formIdstate参数视为destructured discriminated union,因此当判断判别式formId时,state的类型被相应地缩小.


如果您不能放弃泛型,那么您可以通过重构您的行为,从而将切换替换为索引到对象来追求放弃控制流缩小.这使您可以使用在microsoft/TypeScript#47109中实现的支持来执行看起来像是单个通用操作的操作.例如:

const getNextRoute = <F extends FORM_ID>(formId: F, state: StateType<F>) => {
  const defaultCase = () => { return '/' };
  const cases: { [F in FORM_ID]: (state: StateType<F>) => string } = {
    [FORM_ID.STEP_1]: state => {
      if (state?.a) {
        return '/step-3'
      }
      return '/step2'
    },
    [FORM_ID.STEP_2]: defaultCase,
    [FORM_ID.STEP_3]: defaultCase
  }
  return cases[formId](state);
};

在这里,我用一个名为cases的对象替换了switch/case,该对象的键对应于formId(类型为F),其值是其参数对应于state(类型为StateType<F>)的函数.然后,通过判断单个代码块cases[formId](state),编译器理解这是允许的.

这是一个复杂的重构,可能会让大多数阅读它的人感到困惑.但是,如果您确实需要泛型(比如函数的返回类型也是SomeOtherType<F>),并且仍然希望编译器遵循您的逻辑,这是目前我所知道的使其工作的唯一方法.


所以,这就对了.泛型和控制流分析不能很好地结合在一起;您可以使用其中一个,但不能使用另一个,或者干脆完全放弃并使用类型断言.

Playground link to code

Typescript相关问答推荐

类型脚本如何将嵌套的通用类型展开为父通用类型

类型安全JSON解析

如果一个变量不是never类型,我如何创建编译器错误?

当我点击外部按钮时,如何打开Html Select 选项菜单?

如何在使用`Next—intl`和`next/link`时导航

使用axios在ngOnInit中初始化列表

如何在另一个参数类型中获取一个参数字段的类型?

为什么不能使用$EVENT接收字符串数组?

如何使所有可选字段在打印脚本中都是必填字段,但不能多或少?

如何在Typescript 中组合unions ?

在排版修饰器中推断方法响应类型

如何按属性或数字数组汇总对象数组

如何将类似数组的对象(字符串::匹配结果)转换为类型脚本中的数组?

界面中数组中的混合类型导致打字错误

错误:NG02200:找不到类型为';对象';的其他支持对象';[对象对象]';.例如数组

为什么TS2339;TS2339;TS2339:类型A上不存在属性a?

使用Type脚本更新对象属性

从接口推断模板参数?

Typescript,如何从对象的对象中获取分离的类型

具有来自其他类型的修改键名称的 TypeScript 通用类型