Dark Dwarf Blog background

TypeScript as const

TypeScript as const

1. 引入

在理解 as const 的优点前,我们先看看如何分别用枚举和字面量类型来表示一个 LogLevels

a.a. 枚举

我们可以这样枚举 LogLevels

enum LogLevelEnum {
  Debug = "DEBUG",
  Info = "INFO",
}

这样做的好处是:LogLevelEnum 同时是一个值(一个可以在运行时使用的对象)和一个类型。我们可以方便地用 LogLevelEnum.Info 来引用值。但是枚举会被编译成一个 JavaScript 对象,这会增加最终产出的代码体积。而且这并非原生 JavaScript 的语法。

b.b. 字面量类型

我们可以声明下面的字面量类型:

type LogLevelLiteral = "DEBUG" | "INFO";

它是零运行时开销:类型在编译后会被完全擦除,不产生任何 JavaScript 代码。但是它只定义了类型,如果我们需要使用 'DEBUG' 这个值的话,需要另外定义一个对象来存储:

const LogLevels = {
  Debug: "DEBUG",
  Info: "INFO",
};

c.c. as const

而使用 as const 方法,我们可以如下实现 LogLevels 的定义:

// 1. 定义一个普通对象,但用 as const 锁定它的类型
const LogLevels = {
  Debug: "DEBUG",
  Info: "INFO",
} as const;

// 2. 从这个对象的值中,自动生成类型
type LogLevel = (typeof LogLevels)[keyof typeof LogLevels];

function logger(message: string, level: LogLevel) {
  // ...
}

logger("Message", LogLevels.Info); // OK
// logger("Message", "INFO"); // 也 OK
// logger("Message", "ERROR"); // Error

它完美地结合了上述两种方法的优点:

  1. 拥有了枚举的“值”的优点:LogLevels 是一个真实、标准的 JavaScript 对象,可以在代码中像使用枚举一样通过 LogLevels.Info 来访问值。

  2. 拥有了字面量类型的“类型”的优点:通过 typeofkeyof,我们从 LogLevels 中自动生成了 LogLevel 这个联合类型。这个类型和字面量类型一样,是纯粹的编译时类型,编译后就消失了,零运行时开销。

在现代 TypeScript 开发中,使用 as const 结合 typeofkeyof 已成为替代枚举的推荐模式,它在提供强大类型安全的同时,能够只维护一个对象 LogLevels,而对应的类型 LogLevel 会自动从这个对象的值中生成,保证了值和类型永远同步

2. 补充:keyoftypeof 的一些知识

当我们需要从一个常量对象中提取其值的类型,组合成一个联合类型时,可以使用 keyoftypeof 配合索引访问类型来实现,这是一个非常强大且能保持类型与值同步的模式。

// 使用 as const,TypeScript 会推断出最精确的类型
const LogLevels = {
  Debug: "DEBUG",
  Info: "INFO",
  Warn: "WARN",
} as const;

// 从对象的值创建一个联合类型
type LogLevel = (typeof LogLevels)[keyof typeof LogLevels];
// LogLevel 的类型现在是 'DEBUG' | 'INFO' | 'WARN'

a.a. keyof 操作符

keyof 是 TypeScript 中的一个类型操作符。它的作用是获取一个对象类型的所有键(keys),并返回一个由这些键组成的字符串字面量联合类型。

简单来说,keyof T 会得到类型 T 的所有公开(public)属性名。例如:

type Point = {
  x: number;
  y: number;
};

// keyof Point 的结果是 'x' | 'y'
type PointKeys = keyof Point;

let key: PointKeys;
key = "x"; // OK
key = "y"; // OK
// key = 'z'; // Error: Type '"z"' is not assignable to type 'keyof Point'.

b.b.as const 类型定义的理解

为了理解前面 as const 复杂的类型定义,我们从内到外一步步看:

首先是 typeof LogLevelsLogLevels 是一个用 as const 断言的值(一个 JavaScript 对象)。我们使用了 as const,TypeScript 会推断出最精确的只读类型。所以 typeof LogLevels 的结果是:

{
  readonly Debug: "DEBUG";
  readonly Info: "INFO";
  readonly Warn: "WARN";
}

如果没有 as const,类型会被推断为 { Debug: string; Info: string; Warn: string; },那整个技巧就失效了。这个地方也是 as const 的一个重要用途,后面会提到。

然后是 keyof typeof LogLevels:我们获取类型 { readonly Debug: "DEBUG"; ... } 的所有键,这些键是 DebugInfoWarn,因此返回一个联合类型:"Debug" | "Info" | "Warn"

最后我们将它们组合起来 (typeof LogLevels)[keyof typeof LogLevels]。这一步是整个技巧的核心,它被称为 索引访问类型 (Indexed Access Type) 。它的语法是 T[K]

  • T 就是我们在第 1 步得到的类型:{ readonly Debug: "DEBUG"; ... }
  • K 就是我们在第 2 步得到的类型:'Debug' | 'Info' | 'Warn'

T[K] 表示从类型 T 中,找出 K 联合类型里每一个键所对应的值的类型,然后把这些值的类型组合成一个新的联合类型。

对于我们的例子,具体过程如下:

  1. T['Debug'] 的值的类型是 'DEBUG'
  2. T['Info'] 的值的类型是 'INFO'
  3. T['Warn'] 的值的类型是 'WARN'

把这三个值的类型组合成一个联合类型,最终 LogLevel 的类型就变成了:"DEBUG" | "INFO" | "WARN"

3. 使用 as const阻止类型拓宽

as const 可以阻止 TypeScript 的类型拓宽,是在处理需要精确字面量类型的函数参数或对象数组时非常常见的技巧。下面举一个实际例子。

假设我们有一个接口 ParameterDefinition,它要求 type 属性必须是几个特定字符串之一。

export interface ParameterDefinition {
  index: number;
  type: "body" | "query" | "param"; // <-- 关键在这里
  name?: string;
}

这个接口要求 type 属性的类型必须是 'body', 'query', 'param' 这三个字符串字面量之一的联合类型。它不能是任意的 string

如果不使用 as const,直接创建一个对象并尝试将它添加到一个数组中:

const existingParameters: ParameterDefinition[] = [];

// 尝试推入一个新对象
existingParameters.push({
  index: 0,
  type: "body", // 没有 as const
});

当 TypeScript 看到 'body' 这个字符串时,它的默认行为是进行类型拓宽(Type Widening)。它会认为“这是一个字符串,未来可能被改变成任何其他字符串”,所以它会将 type 属性的类型推断为 string

{
  index: number;
  type: string; // <-- 问题所在!类型是 string
}

这会导致一个编译错误:

// 编译错误!
// Argument of type '{ index: number; type: string; }' is not assignable to parameter of type 'ParameterDefinition'.
//   Types of property 'type' are incompatible.
//     Type 'string' is not assignable to type '"body" | "query" | "param"'.

编译器会报错:你试图传入一个 typestring 的对象,但我需要的是一个 type'body' | 'query' | 'param' 的对象。string 类型太宽泛了,不符合要求。

这时,我们可以在 'body' 后面加上 as const,这样相当于是在告诉 TypeScript:“这个值就是 'body' 这个字面量,而且永远不会变。”

const existingParameters: ParameterDefinition[] = [];

// 这次,我们对 'body' 使用 as const
existingParameters.push({
  index: 0,
  type: "body" as const,
}); // OK!

现在,type 属性的类型就会被精确地推断为字面量类型 'body',而不是 string

{
  index: number;
  type: "body"; // <-- 类型是字面量 'body'
}