【翻译】TypeScript:把类型当作编程语言来编程

0 阅读11分钟

原文链接:TypeScript Types as a Programming Language

作者:Thiery Michel

你知道吗?TypeScript 是图灵完备的。在本文中,我将把类型定义当作编写程序来对待。我的目标并非用 TypeScript 编写Doom游戏或执行数学运算 —— 这些早就有人实现过了:

尽管这些案例令人惊叹,但也引出了一个问题:我们为什么要这么做?答案是:通过将类型当作程序来编写,从而更熟练地掌握类型定义技巧。

泛型类型:类型世界的 “函数”

在 TypeScript 中,你可以定义依赖于其他类型的类型。这类类型的工作方式类似函数:接收一个输入类型,产出一个输出类型。

// 最简单的恒等函数:
const identity = (value) => value;
// 对应到类型层面:
type Identity<Type> = Type;
// 使用方式如下:
type Result = Identity<number>;
// 最终类型 Result = number

// 更复杂的例子:
const createObject = (a, b) => ({ a, b });
// 对应到类型层面:
type CreateObject<A, B> = { a: A, b: B };
// 使用方式如下:
type Result = CreateObject<string, boolean>;
// 最终类型 Result = { a: string; b: boolean }

为泛型类型函数添加类型约束

extends 关键字用于告知 TypeScript,某个类型参数必须是指定类型的子类型,这和为函数参数指定类型类似。

type CreateObject<A extends string, B> = { a: A, b: B };
// A 必须是 string 类型,否则会报错
type Errored = CreateObject<number, boolean>;
// 报错信息:Type 'number' does not satisfy the constraint 'string'
type Result = CreateObject<'name', boolean>;
// 最终类型 Result = { a: "name"; b: boolean; }

为泛型类型函数添加默认类型

你也可以为泛型类型函数指定默认类型,就像为函数参数设置默认值一样。

type CreateObject<
  Key extends string = 'defaultName',
  Value = string
> = { [Key]: Value };
// 现在 Key 和 Value 都是可选参数了
type Result = CreateObject;
// 最终类型 Result = { defaultName: string }
type Result2 = CreateObject<'name'>;
// 最终类型 Result2 = { name: string; }
type Result3 = CreateObject<'name', boolean>;
// 最终类型 Result3 = { name: boolean; }

实战案例:简易 CRUD 类型生成器

借助上述特性,我们已经可以创建一个通用的 CRUD 类型生成器:

type Crud<Resource extends { id: string | number }> = {
  // 创建操作:接收除 id 外的所有字段
  create: (resource: Omit<Resource, 'id'>) => Resource;
  // 查询单个:接收 id,返回资源或 undefined
  getOne: (id: Resource['id']) => Resource | undefined;
  // 更新操作:接收 id 和部分资源(不含 id),返回资源或 undefined
  update: (
    id: Resource['id'],
    resource: Partial<Omit<Resource, 'id'>>
  ) => Resource | undefined;
  // 删除操作:接收 id,返回是否删除成功的布尔值
  delete: (id: Resource['id']) => boolean;
  // 列表查询:接收部分资源(不含 id)作为筛选条件,返回资源数组
  getList: (filter: Partial<Omit<Resource, 'id'>>) => Resource[];
}

// 使用示例
type User = { id: number; name: string; email: string };
type UserCrud = Crud<User>;
// 最终类型 UserCrud = {
//     create: (resource: Omit<User, 'id'>) => User;
//     getOne: (id: number) => User | undefined;
//     update: (id: number, resource: Partial<Omit<User, 'id' >>) =>
//       User | undefined;
//     delete: (id: number) => boolean;
//     getList: (filter: Partial<Omit<User, 'id'>>) => User[];
// }

条件判断:条件类型

我们已经有了 “函数”,那能实现条件判断吗?当然可以。通过 extends 关键字在类型函数中创建条件判断 ——extends 用于检测一个类型是否是另一个类型的子类型,再结合三元表达式语法:条件 ? 成立时的类型 : 不成立时的类型

type IsNumber<Value extends unknown> =
  Value extends number ? true : false;

type Result = IsNumber<7>;
// 最终类型 Result = true
type Result2 = IsNumber<'seven'>;
// 最终类型 Result2 = false

实战案例:从事件中提取事件类型

type CreateEvent = {
  type: "create";
  payload: { name: string }
};
type UpdateEvent = {
  type: "update";
  payload: { id: number; name?: string }
};
type DeleteEvent = {
  type: "delete";
  payload: { id: number }
};
type UnknownEvent = unknown;

type Event = CreateEvent | UpdateEvent | DeleteEvent | UnknownEvent;

type InferEventType<T extends Event> = T extends CreateEvent
  ? "create"
  : T extends UpdateEvent
  ? "update"
  : T extends DeleteEvent
  ? "delete"
  : never;

// 使用示例
type EventType = InferEventType<UpdateEvent>; // 'update'
type EventType2 = InferEventType<CreateEvent>; // 'create'
type EventType3 = InferEventType<DeleteEvent>; // 'delete'
type EventType4 = InferEventType<'An event'>; // never

infer 关键字:类型世界的 “变量”

infer 关键字用于在类型定义中创建 “变量”,支持解构赋值。

// 首先,使用 infer 关键字解构数组的第一个元素并返回
type First<Element> =
  Element extends [infer FirstElement, ...any[]] ? FirstElement : never;

// 使用示例
type Result = First<[string, number, boolean]>;
// 最终类型 Result = string
type Result2 = First<[]>;
// 最终类型 Result2 = never

实战案例

借助 infer,我们可以简化条件类型章节中的事件类型提取示例:

type InferEventType<
  T extends Event
> = T extends { type: infer EventType } ? EventType : never;
// 我们定义了一个变量 EventType,用于提取 T 中的 type 字段
// 无需再逐个检查每种事件类型

// 使用示例
type EventType = InferEventType<UpdateEvent>; // 'update'
type EventType2 = InferEventType<CreateEvent>; // 'create'
type EventType3 = InferEventType<DeleteEvent>; // 'delete'
type EventType4 = InferEventType<{}>; // never

递归类型:类型世界的 “循环”

一个类型可以调用自身,这使得我们能够创建递归类型。

你可以用递归类型来定义递归数据结构,比如树形结构:

type TreeNode<Value> = {
  value: Value;
  children?: TreeNode<Value>[];
}

type StringTree = TreeNode<string>;
const tree: StringTree = {
  value: 'root',
  children: [
    { value: 'child1' },
    { value: 'child2', children: [
        { value: 'grandchild1' }
      ] }
  ]
};

但你也可以用递归遍历数组。让我们实现一个 Find 类型,用于在数组中查找指定类型,找到则返回该类型,否则返回 never

type Find<ArrayType extends unknown[], ValueType> =
  // 将数组解构为第一个元素和剩余元素
  ArrayType extends [infer First, ...infer Rest]
    // 检查第一个元素是否是我们要找的类型
    ? First extends ValueType
      // 若是,返回该元素类型
      ? First
      // 若不是,递归处理剩余数组
      : Find<Rest, ValueType>
    : never; // 若数组为空,返回 never

// 事件数组使用示例
type EventsArray = [{
  type: 'create';
  payload: { name: string; };
}, {
  type: 'update';
  payload: { id: number; name?: string; };
}, {
  type: 'delete';
  payload: { id: number; };
}];

type CreateEvent = Find<
  EventsArray,
  { type: 'create'; payload: { name: string; }; }
>;
// 最终类型 CreateEvent = { type: "create"; payload: { name: string; }; }
type UpdateEvent = Find<
  EventsArray,
  { type: 'update'; payload: { id: number; name?: string; }; }
>;
// 最终类型 UpdateEvent = {
//   type: "update";
//   payload: { id: number; name?: string | undefined; };
// }
type DeleteEvent = Find<
  EventsArray,
  { type: 'delete'; payload: { id: number; }; }
>;
// 最终类型 DeleteEvent = { type: "delete"; payload: { id: number; }; }
type UnknownEvent = Find<
  EventsArray,
  { type: 'unknown'; payload: {}; }
>;
// 最终类型 UnknownEvent = never

实战案例:为中间件结果定义类型

你可能会好奇这在实际开发中有什么用。想象一个中间件系统,中间件会根据操作参数修改操作结果。已知中间件类型,我们需要找到特定参数对应的结果类型:

// 中间件类型定义:
// 接收 Arg 类型的参数,返回 Result 类型的结果
type Middleware<Arg, Result> = (arg: Arg) => Result;

// 示例中间件集合
type Middlewares = [
  Middleware<
    { type: "getList"; withComments: true },
    { id: number; name: string; comments: string[] }[]
  >,
  Middleware<{ type: "getList" }, { id: number; name: string }[]>,
  Middleware<
    { type: "getOne"; withComments: true },
    { id: number; name: string; comments: string[] }
  >,
  Middleware<{ type: "getOne" }, { id: number; name: string }>
];

// 查找参数类型匹配 Target 的中间件
type FindMiddleware<Target> = Find<Middlewares, Middleware<Target, any>>;

// 获取中间件的结果类型
type GetResult<MiddlewareInput extends Middleware<any, any>> =
  MiddlewareInput extends Middleware<any, infer Result> ? Result : never;

// 组合所有逻辑:根据参数获取中间件结果类型
type GetMiddlewareResult<Arg> = GetResult<FindMiddleware<Arg>>;

type ListResult = GetMiddlewareResult<{ type: "getList" }>;
// 最终类型 ListResult = { id: number; name: string; }[]

type OneResult = GetMiddlewareResult<{ type: "getOne" }>;
// 最终类型 OneResult = { id: number; name: string; }

type ListWithCommentsResult = GetMiddlewareResult<{
  type: "getList";
  withComments: true;
}>;
// 最终类型 ListWithCommentsResult = {
//   id: number;
//   name: string;
//   comments: string[]
// }[]

type OneWithCommentsResult = GetMiddlewareResult<{
  type: "getOne";
  withComments: true;
}>;
// 最终类型 OneWithCommentsResult = {
//   id: number;
//   name: string;
//   comments: string[]
// }

字符串操作:模板字面量类型

TypeScript 允许你在类型层面使用模板字面量操作字符串。

你可以执行简单的字符串拼接:

type HelloWorld<Greeted extends string = 'world'> = `Hello ${Greeted}`;

type DefaultResult = HelloWorld; // 'Hello world'
type Result = HelloWorld<'TypeScript'>; // 'Hello TypeScript'

你也可以执行更复杂的字符串操作,例如结合 infer 关键字和递归移除字符串中的所有空格:

type RemoveWhitespace<S extends string> =
  S extends `${infer First} ${infer Rest}`
    ? `${First}${RemoveWhitespace<Rest>}`
    : S;

type Result = RemoveWhitespace<'Hello   World  !'>;
// 最终类型 Result = "HelloWorld!"

实战案例:根据属性名生成 Getter 方法名

type Getter<Key extends string> = `get${Capitalize<Key>}`;
type Result = Getter<'name'>;
// 最终类型 Result = "getName"
type Result2 = Getter<'firstName'>;
// 最终类型 Result2 = "getFirstName"

Capitalize 是 TypeScript 内置的工具类型,用于将字符串类型的首字母大写。你也可以借助 Uppercase 和递归自己实现:

type Capitalize<
  S extends string
> = S extends `${infer First}${infer Rest}`
  ? `${Uppercase<First>}${Rest}`
  : S;
// Uppercase 也是一个工具类型
// 但其底层是通过编译器内置逻辑实现的

// 使用示例
type Result = Capitalize<"hello">;
// 最终类型 Result = "Hello"

映射类型

借助递归,我们可以遍历数组,但如何遍历对象属性呢?映射类型允许你通过转换现有类型的每个属性来创建新类型。映射类型基于索引访问类型和 keyof 运算符的语法构建。

索引访问类型

索引访问类型允许你通过 Type[Key] 语法获取对象类型中某个属性的类型:

type User = { id: number; name: string; email: string };
type UserId = User['id'];
// 最终类型 UserId = number
type UserName = User['name'];
// 最终类型 UserName = string

它也可以结合键的联合类型,获取这些键对应的类型联合:

type UserIdOrName = User['id' | 'name'];
// 最终类型 UserIdOrName = number | string

keyof 运算符

keyof 运算符允许你获取一个类型的所有键组成的联合类型。

type User = { id: number; name: string; email: string };
type UserKeys = keyof User;
// 最终类型 UserKeys = "id" | "name" | "email"

in 关键字

最后,通过添加 in 关键字,我们可以遍历一个类型的所有键。

type UserPropertiesAsString = {
  // 这里 K 是 User 的每个键
  // 我们遍历每个键,创建一个返回该属性类型(User[K])的函数
  [K in keyof User]: () => User[K];
};
// 最终类型 UserPropertiesAsString = {
//   id: () => number;
//   name: () => string;
//   email: () => string;
// }

借助这一特性,你可以创建一个将对象所有方法转换为返回 Promise 的类型:

type Promisify<R extends Record<string, (...args: any) => any>> = {
  [K in keyof R]:
    (...args: Parameters<R[K]>) => Promise<ReturnType<R[K]>>;
};

type Input = {
  getName: () => string;
  getById: (id: string) => { id: string; name: string };
};

type PromisifiedInput = Promisify<Input>;
// 最终类型 PromisifiedInput = {
//     getName: () => Promise<string>;
//     getById: (id: string) => Promise<{
//         id: string;
//         name: string;
//     }>;
// }

实战案例

以下是一个更复杂的映射类型示例:接收一个资源类型和一组筛选键,为每个筛选键生成 getByFilterType 方法:

type Product = {
  id: number;
  name: string;
  quantity: number;
  inStock: boolean;
};
type CrudWithFilters<
  Resource extends { id: string | number },
  FilterKeys extends keyof Resource
> = {
  // 为每个筛选键生成 getByXXX 方法
  [K in FilterKeys as `getBy${Capitalize<string & K>}`]:
    (value: Resource[K]) => Resource[];
}
type ProductCrudWithFilters = CrudWithFilters<
  Product,
  'name' | 'quantity' | 'inStock'
>;
// 最终类型 ProductCrudWithFilters = {
//     getByName: (value: string) => Product[];
//     getByQuantity: (value: number) => Product[];
//     getByInStock: (value: boolean) => Product[];
// }

// 实际函数使用示例
const productCrud: CrudWithFilters<
  Product,
  'name' | 'quantity' | 'inStock'
> = {
  getByName: (name: string) => [
    { id: 1, name, quantity: 10, inStock: true }
  ],
  getByQuantity: (quantity: number) => [
    { id: 2, name: 'Product 2', quantity, inStock: false }
  ],
  getByInStock: (inStock: boolean) => [
    { id: 3, name: 'Product 3', quantity: 5, inStock }
  ],
};

// productCrud 的类型会被正确推断
// 若函数实现与预期类型不匹配,会触发类型错误
const erroredProductCrud: CrudWithFilters<
  Product,
  'name' | 'quantity' | 'inStock'
> = {
  getByName: (name: number) => [
    { id: 1, name: 'Product 1', quantity: 10, inStock: true }
  ],
  // 错误信息:
  // 类型 '(name: number) => { id: number; name: string; quantity: number; inStock: true; }[]'
  // 不能赋值给类型 '(value: string) => Product[]'
  // 参数 'name' 和 'value' 的类型不兼容
  //   类型 'string' 不能赋值给类型 'number'
  getByQuantity: (quantity: string) =>
    [{ id: 2, name: 'Product 2', quantity: 5, inStock: false }],
  // 错误信息:
  // 类型 '(quantity: string) => { id: number; name: string; quantity: number; inStock: false; }[]'
  // 不能赋值给类型 '(value: number) => Product[]'
  // 参数 'quantity' 和 'value' 的类型不兼容
  //   类型 'number' 不能赋值给类型 'string'
  getByInStock: (inStock: boolean) => 'not found',
  // 错误信息:
  // 类型 '(inStock: boolean) => string'
  // 不能赋值给类型 '(value: boolean) => Product[]'
  //   类型 'string' 不能赋值给类型 'Product[]'
};

将函数对象简化为单个函数

借助 keyof,我们可以根据对象的结构返回任意类型,而不仅仅是对象。例如,以下函数将一个函数对象转换为单个函数:第一个参数是对象的键,后续参数是对应函数的参数。

// 首先定义辅助类型:
// ObjToFunc 接收任意函数对象,将其转换为单个函数
type ObjectToFunc<Obj extends Record<string, (...args: any) => any>> = {
  // 遍历函数对象的键
  // 为每个键定义一个函数
  <K extends keyof Obj>(key: K, ...args: Parameters<Obj[K]>): ReturnType<
    Obj[K]
  >;
};

// 结果是对象每个键对应的函数组成的联合类型
type Example = ObjectToFunc<
  {
    getStringLength: (s: string) => number;
    isEven: (n: number) => boolean;
  }
>;
// 最终类型 Example =
// | (key: 'getStringLength', s: string) => number
// | (key: 'isEven', n: number) => boolean;

// 现在实现实际函数
const transformObjectToFunction = <
  Obj extends Record<string, (...args: any) => any>
>(
  obj: Obj
): ObjectToFunc<Obj> => {
  return ((key: string, ...args: unknown[]) => {
    const func = obj[key];
    return func(...args);
  }) as ObjectToFunc<Obj>;
};

// 使用示例:将多个资源的 getList 函数组合为单个 getList 函数
const getList = transformObjectToFunction({
  authors: (filter: { name?: string }) => [
    { id: 1, name: "Author 1" },
    { id: 2, name: "Author 2" },
  ],
  posts: (filter: { authorId?: number; published?: boolean }) => [
    { id: 1, title: "A post" },
  ],

  comments: (filter: { postId?: number; authorId?: number }) => [
    { id: 1, content: "A comment" },
  ],
});

getList("posts", { authorId: 1 });
// 无错误
// 推断类型为:
// const getList: <"posts">(key: "posts", filter: {
//     authorId?: number;
//     published?: boolean;
// }) => {
//     id: number;
//     title: string;
// }[]

getList("posts", { name: 'Author 1' });
// 错误:对象字面量只能指定已知属性,
// 而 'name' 不在类型 '{ authorId?: number | undefined; published?: boolean | undefined; }' 中

getList("authors", { name: "Author 1" });
// 无错误
// 推断类型为:
// const getList: <"authors">(key: "authors", filter: {
//     name?: string | undefined;
// }) => {
//     id: number;
//     name: string;
// }[]

:上述示例使用了 TypeScript 提供的工具类型 ReturnTypeParameters。> ReturnType 返回给定函数类型的返回值类型,如果你好奇其实现方式,可以参考以下代码:

type ReturnType<T extends (...args: any) => any> = T extends (
  ...args: any
) => infer R
  ? R
  : any;

Parameters 返回给定函数类型的参数类型组成的元组,其实现方式如下:

type Parameters<T extends (...args: any) => any> = T extends (
  ...args: infer P
) => any
  ? P
  : never;

总结

将 TypeScript 类型定义当作编程语言对待,让我们能够定义复杂的泛型类型。这些泛型类型进而支持我们编写更通用的代码,减少重复,并提升整个代码库的类型安全性。

核心要点总结:

  • 泛型类型是转换类型的 “函数”
  • 条件类型(extends ? :)支持分支逻辑
  • infer 关键字类似变量赋值,允许通过解构从其他类型中提取值
  • 递归支持遍历数组和复杂类型
  • 模板字面量支持在类型层面操作字符串
  • 映射类型支持遍历对象属性

借助这些工具,你可以创建适配自身需求的复杂类型工具,在编译期捕获错误,并在 IDE 中获得出色的自动补全体验。下次编写类型定义时,不妨把它当作编写程序 —— 因为这正是你正在做的事情。