我正在编写一个API的包装器.根据传入的类型,API需要不同的参数.我希望这个不变量由类型脚本编译器强制执行,而不会引入任何不安全的行为,例如强制转换.

我在下面提供了一个我想要做的事情的例子.在这个类比中,eat是API调用,Food是可能的输入参数,Fruit是我想要求调用者提供额外输入的参数的子集.MaybePeel是一个"帮助器"类型,当传递Fruit时,它将peel属性添加到EatParams类型.

我的方法有两个问题,可能是因为我缺乏打字知识.第一个是在方法上.当访问food.peel时,我希望TypeScrip知道该属性必须存在.这是可行的,但问题是,TypeScrip允许我访问else中的该属性,而该属性绝对不应该存在.第二个是在调用eat方法时--TypeScrip要求Pizza提供peel.

TypeScript Playground

type Apple = { name: "Apple" }
type Peach = { name: "Peach" }
type Pizza = { name: "Pizza" }

type Food = Apple | Peach | Pizza
type Fruit = Apple | Peach

// we should potentially peel fruit before eating, but we should never peel pizza (how would you even do that?)
type MaybePeel<T extends Food> = T extends Fruit ? {
  peel: boolean;
} : never;

// For Pizza, we should just take a quantity parameter; never ask to peel
// For Fruit, we should take both quantity and a `peel` boolean
type EatParams<T extends Food> = {
  food: T;
  quantity: number;
} & MaybePeel<T>

function eat<T extends Food>(params: EatParams<T>) {
  const { food } = params;

  if (food.name === "Peach" || food.name === "Apple") {
    // TypeScript should infer that `peel` must be set in this case because of `MaybePeel`
    if (params.peel) {
      // code to peel my food
    }
  } else {
    // TypeScript should throw an error here because `Pizza` never takes a `peel` parameter
    // @ts-expect-error
    params.peel
  }

  // code to eat my food
}

// Pizza should not allow `peel` to be passed in 
eat({ food: { name: "Pizza" }, quantity: 1 })
// Apple and Peach should require a `peel` to be passed in
eat({ food: { name: "Apple" }, quantity: 1, peel: true })

推荐答案

不幸的是,eat()成为一个generic的函数是没有帮助的.

如果您的eat()函数实现使用control flow analysis来为不同的情况做不同的事情(例如使用if/elseswitch/case),那么在T extends Food中使用它就没有多大意义.当您想要同时做一件适用于多个情况的事情时,泛型函数更有用.

food.name === "Peach"这样的复选标记会影响food.name的表观类型,might会影响food的类型,但不会对泛型类型参数T产生任何影响.

有各种开放的特性请求要求更好地支持控制流分析的泛型类型参数,例如microsoft/TypeScript#27808microsoft/TypeScript#33014,但目前它们还不是语言的一部分.因此,在接下来的内容中,我将忘记将eat()设置为泛型.


您可能应该使用union types而不是泛型.所以EatParams<T>只会变成EatParams,一个有一个成员的联盟,每个成员对应着每种不同的叫法.您可以手动执行此操作:

type EatParams =
  { food: Apple; quantity: number; peel: boolean; } |
  { food: Peach; quantity: number; peel: boolean; } |
  { food: Pizza; quantity: number; };

或者您可以重用您的FoodFruitMaybePeel类型(尽管最后一个类型需要更改以修复逻辑错误...也许你只想编辑问题,这样我就不用这么说了):

type MaybePeel<T extends Food> =
  T extends Fruit ? { peel: boolean; } : unknown // <-- not never

type EatParams = { [F in Food as F["name"]]: 
  { food: F, quantity: number } & MaybePeel<F> 
}[Food["name"]];

我不想在解释EatParams公式时离题太多,只是说它是一种"分布式对象类型",就像在ms/TS#47109中创造的那样,涉及indexing intoamapped type.


不幸的是,这仍然不能直接起作用:

function badEat(params: EatParams) {
  if (params.food.name === "Peach" || params.food.name === "Apple") {
    if (params.peel) { } // error!
  }
}

这是因为params.food.name中的equality checking只影响params.food.name的表观类型.它不影响params的类型,因为即使EatParams是一个联盟,它也不被认为是discriminated union.判别性属性必须是顶级的.TypeScrip不支持microsoft/TypeScript#18758中所要求的"嵌套"歧视联合.所以params.foodName可能会起作用,但params.food.name什么也做不了.


在这一点上,您有一些 Select .一种是重构,通过在顶部添加一个foodName属性或其他东西,使eat()的输入成为一个真正的有区别的联合.

另一种方法是放弃受歧视的并集,转而使用现有的并集,以及模拟嵌套的鉴别式的custom type guard function.可能是这样的:

function isFoodType<T extends Food["name"]>(
  e: EatParams, type: T
): e is Extract<EatParams, { food: { name: T } }> {
  return e.food.name === type
}

这接受EatParams参数e以及泛型类型T的食物类型type的名称.该函数充当类型保护,将eEatParams缩小到food.name属性属于T类型的成员(如果它返回true),或者缩小到food.name属性是T类型not的成员(如果它返回false).

然后编写eat()函数,如下所示:

function eat(params: EatParams) {
  if (isFoodType(params, "Peach") || isFoodType(params, "Apple")) {
    if (params.peel) { } // okay
  } else {
    params.peel // error
    // Property 'peel' does not exist on type '{ food: Pizza; quantity: number; }'.
  }
}

看起来不错;编译器知道params在True分支中必须具有peel属性,而在False分支中具有peel属性是未知的.


从调用者的Angular 来看,这也(基本上)按预期工作.好的呼叫是允许的:

eat({ food: { name: "Pizza" }, quantity: 1 }) // okay
eat({ food: { name: "Apple" }, quantity: 1, peel: true }) // okay

显然,糟糕的电话会被拒绝:

eat({ food: { name: "Apple" }, quantity: 1 }) // error

以下呼叫是allowed:

eat({ food: { name: "Pizza" }, quantity: 1, peel: true }) // okay

您可能会认为peel在这里是被禁止的,因为它是an excess property,但过度的属性判断只在某些情况下发生.在本例中,您使用的是非歧视并集,在这种情况下,"交叉项"属性不会被认为是多余的.有关详细信息,请参阅microsoft/TypeScript#35890.

希望这不是什么大问题,因为房产过剩很少成为type safety个问题.如果传入peel,则eat()函数实现实质上将忽略peel.

如果这是一个问题,并且您希望为披萨定义一个peel属性prohibit,那么您应该将MaybePeel更改为类似于

type MaybePeel<T extends Food> =
  T extends Fruit ? { peel: boolean; } : { peel?: never }

在这一点上,除了undefined之外的任何类型的peel上都会出现错误,虽然params.peel不再是eat()中的错误,但编译器将知道它在PIZATE代码块中是undefined.


差不多就是这样.

最后一件事:您不能像const { food } = params;一样使用destructure params,并期望编译器在之后保持foodparams类型之间的相关性.有一些人支持destructured discriminated union types,但这对你没有帮助,因为你的类型不是一个受歧视的联盟.即使是这样,你也需要拆解所有的房产,而不仅仅是food处.而你不能用rest property做到这一点,至少在microsoft/TypeScript#46680实现之前是这样的.目前,要做到这一点,唯一的方法是直接使用params,而不是试图为它的一个属性设置别名.

Playground link to code

Typescript相关问答推荐

Angular 17 -如何使用新的@if语法在对象中使用Deliverc值

如何缩小变量访问的对象属性范围?

数组文字中对象的从键开始枚举

带switch 的函数的返回类型的类型缩小

是否存在类型联合的替代方案,其中包括仅在某些替代方案中存在的可选属性?

诡异的文字类型--S为物语的关键

CDK从可选的Lambda角色属性承担角色/复合主体中没有非空断言

如何在已知基类的任何子类上使用`extends`?

如何在typescript中为this关键字设置上下文

Angular 自定义验证控件未获得表单值

正交摄影机正在将渲染的场景切成两半

TypeScrip错误:为循环中的对象属性赋值

使用ngfor循环时,数据未绑定到表

在抽象类构造函数中获取子类型

如何正确键入泛型相对记录值类型?

是否可以将类型参数约束为不具有属性?

从泛型对象数组构造映射类型

覆盖高阶组件中的 prop 时的泛型推理

TS 条件类型无法识别条件参数

通用可选函数参数返回类型