[译]如何在 Typescript 中实现通用的 Valueof<T> 辅助泛型

Sudhanshu 昨天在KCD Discord上问了一个关于 Typescrpit 有趣的问题:

是否可以将变量的类型限制为普通对象的值?

我提供了解决方案,但他想了解其中的原理,所以我就顺手写了这篇文章来与各位分享这点知识。

我们先从纯 Javascript 代码开始,看一个符合 Sudhanshu 要求的在运行时执行校验的例子

const SHAPES = {
  SQUARE: 'square',
  CIRCLE: 'circle',
};

const value = 'square';

// 校验 value 是否是 SHAPES 的 values 之一
const validValues = Object.values(SHAPES);
const isValid = validValues.includes(value);

if (!isValid) {
  throw new TypeError(
    `'value' should be one of: ${validValues.join(' | ')}`
  );
}

以上代码将会在 value 不等于 square 或 circle 时抛出错误。运行时校验挺好的,但问题是这些工作是否可以通过 Typescrpit 在静态阶段完成。

哈哈,这当然可以啦!

限制对象的值

我们面临的第一个挑战是,我们在操作一个对象(object)而不是一个类型(type)。

所以,我们应该先从对象提取出类型,那么就该使用 typeof

const SHAPES = {
  SQUARE: 'square',
  CIRCLE: 'circle',
};

type Shape = typeof SHAPES;

现在类型 Shape 就是

type Shape = { 
  SQUARE: string;
  CIRCLE: string;
}

但这还不是我想要的。

如果我们需要验证 value 是否包含在对象 SHAPES 的值(square | circle)中,我们当然需要验证。我们可以通过关键字as const将对象声明为常量,加上该申明后,我们向 Typescript 保证我们不会在运行时改变这一对象,Typescript 会将它视为一种类似于枚举类型的对象。

const SHAPES = {
  SQUARE: 'square',
  CIRCLE: 'circle',
} as const;

加了as const后,类型 Shape 就变为

type Shape = { 
  readonly SQUARE: 'square';
  readonly CIRCLE: 'circle'; 
}

解释一下这里发生了什么:

  • 首先,所有属性都被标记为只读readonly,此后再修改该对象都将会收到来自 typescript 的错误提示
  • 其次,现在各属性都被限制为它们对应的枚举值,而不是之前的 string 类型。

有了它之后,我们就得到了一个可以使用的类型了。Typescript 并没有 valueof 这样的辅助类型,但我们知道它有一个 keyof,看看它能不能帮到我们。

type keys = keyof Shape;

keyof 从以上类型 Shape 中获取它所有键的联合类型,所以类型 keys 就等同于:

type keys = 'SQUARE' | 'CIRCLE';

有了键,那么我们就能获取到值了。其实或许到这,你可能就已经知道该如何提取值并复用它了。

就比如,如果你想得到 SQUARE 的类型,那么你可以:

type Square = Shape['SQUARE']; // square

现在,你可以基于以上Square类型声明一个新的联合类型,大家应该会这么写:

type ValidShapes = Shape['SQUARE'] | Shape['CIRCLE']; // square | circle

当然,你也可以改为这样更简短的方式:

type ValidShapes = Shape['SQUARE' | 'CIRCLE']; // square | circle

我们来总结一下。以上我们使用keyof来获取反映Shpae键的联合类型,以及一个比较简捷的从值创建联合类型的方法。现在我们再回头看以上最后一个代码片段,你会发现索引参数不过是一个联合类型。其实,其实我们可以直接将keyof丢在里面。

汇总一下以上关键代码片段:

// 使用 as const 将对象声明为常量,因此 ts 将其识别为枚举类型
const SHAPES = {
  SQUARE: 'square',
  CIRCLE: 'circle',
} as const;

// 从对象 SHAPES 中创建一个类型
type Shape = typeof SHAPES;

// 从对象的键 (SQUARE | CIRCLE) 创建一个联合类型
type Shapes = keyof Shape;

// 从对象的值 (square | circle) 创建一个联合类型
type Values = Shape[Shapes];

然后我们就可以用类型 Values 来限定属性的类型了。

const shape: Values = 'circle';

当我们尝试给类型Values限定的变量赋除 square 或 circle 之外的任何值时,typescript 都将会抛出错误。

那么,既然完成任务,今天的文章就结束啦!

多提一句,虽然运行时校验真的很棒,但是现在我们已经不再需要它了,因为如果我们赋了一个不被支持的值时,甚至都不能通过编译 🤷‍♂️。

通用的valueof

其实上面的做法已经很够用了,但,如果能让它实现复用岂不是更棒?于是,我决定再多写两行,用上在 typescript 中比较高级的泛型。

我们重复下解决方案:

type Shape = typeof SHAPES;
type Shapes = keyof Shape;
type Values = Shape[Shapes];

让我们尝试将它转成泛型的实现。

第一步先将 type 收敛成一行,其实就是之前提到的将keyof丢在索引中。当然我们并不打算将typeof也内联 ,虽然加上并不难,但是就会变得很复杂,有机会我下次单独讨论。

type Values = Shape[keyof Shape];

类型Values其实与之前没差,用法也一样 const shape: Values = 'circle',现在看看泛型的写法:

type Values     = Shape[keyof Shape];
type ValueOf<T> = T    [keyof T];

我加了一些空格做前后对比,所以其实应该能一目了然:

  • 首先,我们将类型变量 T 附加到类型上,这里的 T 是一种特殊的变量(不熟悉泛型的同学我多说一句,这里的 T 可以是任意的大写字母),它只适用于类型而不是值。
  • 其次,我们不再使用具体的类型Shape,而是直接使用该变量 T 作为参数。其实这里就是使用 T 替换了Shape

很简单,就这些了,这回真的写完了。你可以放心的将 Valueof添加到你的typescript项目工具类型声明中去了。

// e.g.: global.d.ts
type ValueOf<T> = T[keyof T];

// Shape 是类型
const circle: ValueOf<Shape> = 'circle';
const rectangle: ValueOf<Shape> = 'rectangle'; // err

// SHAPES 是 JS 对象
const circle: ValueOf<typeof SHAPES> = 'circle';
const rectangle: ValueOf<typeof SHAPES> = 'rectangle'; // err
### TypeScript 中的 `never` 类TypeScript 中,`never` 是一个特殊的类,表示那些永不存在的值的类。当函数永远不会返回或者变量永远不会有合法赋值时可以使用该类。 #### 使用传递 `never` 当向参数传递 `never` 类时,编器能够推断出更具体的类信息并执行额外的优化或错误检测。下面是一个简单的例子来展示这种行为: ```typescript function createArray<T>(length: number, value: T): Array<T> { let result: T[] = []; for (let i = 0; i < length; i++) { result[i] = value; } return result; } // 当传入 never 类作为参数时 const arrNever = createArray<never>(3, (() => { throw new Error('Not reachable'); })()); ``` 在这个案例中,尝试创建一个由不可达代码产生的数组[^1]。由于 `value` 的初始化表达式总是抛出异常而不会正常结束,因此其类被指定为 `never`。这意味着任何试图访问 `arrNever` 数组元素的操作都将失败,因为在运行期间这些位置实际上从未被赋予过有效值。 对于带有条件类的高级场景来说,`never` 可以用来实现更加精细的控制逻辑。例如,在某些情况下可以根据输入数据的不同情况排除掉不可能发生的分支: ```typescript type HasNameProperty<T> = T extends { name?: infer U } ? ([U] extends [string | undefined] ? true : false) : false; function processItem<T>(item: T & (HasNameProperty<T> extends true ? {} : 'name')): void {} processItem({}); // OK processItem({ age: 27 }); // Error: Argument of type '{ age: number; }' is not assignable to parameter of type '"name"'. processItem({ name: "Alice", age: 27 }); // OK ``` 这里定义了一个辅助 `HasNameProperty` 来判断对象是否有名为 `name` 属性且属性值可能是字符串或者是未定义的情况;如果不符合则期望提供 `'name'` 字符串字面量形式的补充说明。通过这种方式利用了 `never` 对于不匹配路径自动消除的特点实现了更好的开发体验和安全性保障[^2]。
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值