Skip to content

TypeScript 类型体操实践

TypeScript 的类型系统异常强大,远超出简单的类型标注功能。通过类型编程(Type Programming),我们可以在编译时实现复杂的类型推导和转换,这种实践通常被戏称为"类型体操"。本文将介绍一系列实用的 TypeScript 类型挑战,帮助你掌握类型编程的精髓。

什么是类型体操?

类型体操是指利用 TypeScript 的类型系统进行复杂类型操作和转换的实践。这些操作往往看起来很像"编程",只不过它们完全在类型层面工作,由编译器在编译时计算。

特点:

  • 完全在编译时执行,不产生运行时开销
  • 主要通过条件类型、映射类型、递归类型等实现
  • 可以大幅增强代码的类型安全性
  • 具有解谜游戏般的乐趣

基础挑战

让我们从一些基础的类型挑战开始:

1. 实现 Pick<T, K>

从对象类型中选取指定属性集合:

typescript
// 挑战:实现一个类型 MyPick,类似于内置的 Pick 类型
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P]
}

// 用例
interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type TodoPreview = MyPick<Todo, 'title' | 'completed'>;
// 结果: { title: string; completed: boolean; }

解析:

  • K extends keyof T 确保我们只能选择 T 中存在的键
  • [P in K] 遍历 K 中的每个属性
  • T[P] 保留原对象中该属性的类型

2. 实现 Readonly<T>

将对象的所有属性设为只读:

typescript
// 挑战:实现一个类型 MyReadonly,使所有属性只读
type MyReadonly<T> = {
  readonly [P in keyof T]: T[P]
}

// 用例
interface User {
  name: string;
  age: number;
}

const user: MyReadonly<User> = {
  name: 'John',
  age: 30
};

// 错误:无法分配给"name",因为它是只读属性
// user.name = 'Tom';

解析:

  • keyof T 获取 T 的所有属性键
  • [P in keyof T] 遍历所有键
  • readonly 修饰符将每个属性标记为只读

中级挑战

接下来,让我们尝试一些更复杂的挑战:

3. 实现 DeepReadonly<T>

递归地将嵌套对象的所有属性设为只读:

typescript
// 挑战:实现深度只读类型转换
type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object 
    ? T[P] extends Function 
      ? T[P] 
      : DeepReadonly<T[P]>
    : T[P]
}

// 用例
interface NestedObject {
  name: string;
  settings: {
    theme: string;
    notification: {
      email: boolean;
      sms: boolean;
    }
  }
}

const obj: DeepReadonly<NestedObject> = {
  name: 'App',
  settings: {
    theme: 'dark',
    notification: {
      email: true,
      sms: false
    }
  }
};

// 以下所有赋值操作都会导致编译错误
// obj.name = 'New App';
// obj.settings.theme = 'light';
// obj.settings.notification.email = false;

解析:

  • 递归地处理嵌套对象
  • 使用条件类型 T[P] extends object ? ... : ... 检查是否为对象
  • 对函数类型做特殊处理,避免过度递归
  • 递归应用 DeepReadonly 到嵌套对象属性

4. 实现 Flatten<T>

将嵌套数组展平:

typescript
// 挑战:实现数组扁平化
type Flatten<T extends any[]> = 
  T extends [infer First, ...infer Rest]
    ? First extends any[]
      ? [...Flatten<First>, ...Flatten<Rest>]
      : [First, ...Flatten<Rest>]
    : [];

// 用例
type NestedArray = [1, [2, 3], [4, [5, 6]]];
type FlatArray = Flatten<NestedArray>;
// 结果: [1, 2, 3, 4, 5, 6]

解析:

  • 使用 infer 推断数组的第一个元素 First 和剩余元素 Rest
  • 如果 First 是数组,递归地展平它
  • 否则,保留 First 作为单个元素
  • 最后,递归地处理剩余元素 Rest

5. 实现 TupleToObject<T>

将元组转换为对象:

typescript
// 挑战:将元组转换为对象,其中元素值作为键和值
type TupleToObject<T extends readonly (string | number | symbol)[]> = {
  [P in T[number]]: P
}

// 用例
const tuple = ['tesla', 'model3', 'model X', 'model Y'] as const;
type Result = TupleToObject<typeof tuple>;
// 结果: { tesla: 'tesla'; 'model3': 'model3'; 'model X': 'model X'; 'model Y': 'model Y' }

解析:

  • T[number] 获取元组中的所有元素类型的联合
  • [P in T[number]]: P 将每个元素作为键和值创建对象类型

高级挑战

现在,让我们挑战一些真正高难度的类型体操:

6. 实现 Promise.all 的类型

Promise.all 函数提供正确的类型定义:

typescript
// 挑战:实现 Promise.all 的类型
declare function PromiseAll<T extends any[]>(
  values: readonly [...T]
): Promise<{
  [K in keyof T]: T[K] extends Promise<infer R> ? R : T[K]
}>;

// 用例
const promise1 = Promise.resolve(1);
const promise2 = Promise.resolve('2');
const promise3 = Promise.resolve(true);

const result = PromiseAll([promise1, promise2, promise3]);
// 类型为: Promise<[number, string, boolean]>

解析:

  • 使用泛型 T extends any[] 捕获输入数组的类型
  • 使用映射类型解包每个 Promise
  • 对于每个位置 K,检查 T[K] 是否为 Promise
  • 如果是 Promise,提取其解析值类型,否则保持原类型

7. 实现 Chainable 类型

实现链式调用的类型推导:

typescript
// 挑战:实现链式调用的类型
type Chainable<T = {}> = {
  option<K extends string, V>(
    key: K extends keyof T ? never : K,
    value: V
  ): Chainable<T & { [P in K]: V }>;
  get(): T;
};

// 用例
const config = {};

const result = (config as Chainable)
  .option('name', 'typescript')
  .option('version', 4.2)
  .option('features', ['type-safety', 'tooling'])
  .get();

// 类型为: { name: string; version: number; features: string[] }

解析:

  • 使用泛型参数 T 跟踪已添加的属性
  • option 方法接受键 K 和值 V,并返回包含新属性的 Chainable
  • 使用 K extends keyof T ? never : K 防止重复添加相同的键
  • get 方法返回最终结果对象

8. 实现 ParseQueryString

将查询字符串解析为对象类型:

typescript
// 挑战:解析查询字符串
type ParseQueryString<S extends string> = 
  S extends `${infer Param}&${infer Rest}`
    ? MergeParams<
        ParseParam<Param>,
        ParseQueryString<Rest>
      >
    : ParseParam<S>;

type ParseParam<P extends string> = 
  P extends `${infer Key}=${infer Value}`
    ? { [K in Key]: Value }
    : {};

type MergeParams<
  P1 extends Record<string, any>,
  P2 extends Record<string, any>
> = {
  [K in keyof P1 | keyof P2]: 
    K extends keyof P1
      ? K extends keyof P2
        ? P1[K] | P2[K]
        : P1[K]
      : K extends keyof P2
        ? P2[K]
        : never;
};

// 用例
type Query = ParseQueryString<'foo=bar&baz=qux&foo=quux'>;
// 结果: { foo: "bar" | "quux"; baz: "qux" }

解析:

  • 使用模板字面量类型和 infer 解析查询字符串
  • 递归地处理每个参数对
  • 处理同名参数,将其值合并为联合类型
  • 最终构造出完整的对象类型

极限挑战

最后,让我们尝试一些极限挑战,展示 TypeScript 类型系统的强大能力:

9. 实现 Currying 类型

为函数柯里化提供类型支持:

typescript
// 挑战:实现函数柯里化的类型
type Currying<F> = 
  F extends (...args: infer Args) => infer Return
    ? Args extends [infer First, ...infer Rest]
      ? Rest['length'] extends 0
        ? F
        : (arg: First) => Currying<(...args: Rest) => Return>
      : F
    : never;

// 用例
const add = (a: number, b: number, c: number) => a + b + c;
const curriedAdd: Currying<typeof add> = 
  a => b => c => a + b + c;

// 类型安全
const result1 = curriedAdd(1)(2)(3); // 正确: number
// const result2 = curriedAdd(1)(2); // 错误: 缺少最后一个参数
// const result3 = curriedAdd(1, 2); // 错误: 应当逐个应用参数

解析:

  • 解构函数类型,提取参数类型 Args 和返回类型 Return
  • 逐步解构参数,将每个参数转换为单独的函数调用
  • 递归处理,直到所有参数都被消费

10. 实现类型版的 FizzBuzz

在类型级别实现著名的 FizzBuzz 问题:

typescript
// 挑战:类型级别的 FizzBuzz
type Numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];

type FizzBuzz<
  T extends number[],
  Result extends any[] = []
> = T extends [infer First extends number, ...infer Rest extends number[]]
  ? FizzBuzz<
      Rest,
      [
        ...Result,
        First extends number
          ? DivisibleBy<First, 15> extends true
            ? 'FizzBuzz'
            : DivisibleBy<First, 3> extends true
              ? 'Fizz'
              : DivisibleBy<First, 5> extends true
                ? 'Buzz'
                : First
          : never
      ]
    >
  : Result;

type DivisibleBy<N extends number, M extends number> = 
  N extends 0 
    ? true 
    : N extends M 
      ? true 
      : N extends 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14
        ? false
        : DivisibleBy<Subtract<N, M>, M>;

type Subtract<A extends number, B extends number> = 
  TupleOfLength<A> extends [...TupleOfLength<B>, ...infer Rest]
    ? Rest['length']
    : 0;

type TupleOfLength<N extends number, T extends any[] = []> = 
  T['length'] extends N ? T : TupleOfLength<N, [...T, any]>;

// 用例
type Result = FizzBuzz<Numbers>;
// 结果: [1, 2, "Fizz", 4, "Buzz", "Fizz", 7, 8, "Fizz", "Buzz", 11, "Fizz", 13, 14, "FizzBuzz"]

解析:

  • 在类型系统中实现 FizzBuzz 问题
  • 使用递归类型处理数组中的每个数字
  • 实现类型级别的算术运算(减法和整除判断)
  • 通过元组长度进行数值运算

实用工具类型

类型体操不仅仅是智力游戏,它能解决实际开发中的类型问题。以下是一些实用的工具类型:

1. 提取嵌套对象的路径类型

typescript
// 获取对象的所有可能路径
type PathsOf<T, D extends string = ""> = T extends object
  ? {
      [K in keyof T]: K extends string
        ? PathsOf<T[K], D extends "" ? K : `${D}.${K}`> | (D extends "" ? K : `${D}.${K}`)
        : never;
    }[keyof T]
  : D;

// 用例
interface User {
  name: string;
  address: {
    street: string;
    city: string;
    geo: {
      lat: number;
      lng: number;
    };
  };
  orders: {
    id: number;
    items: {
      productId: number;
      quantity: number;
    }[];
  }[];
}

type UserPaths = PathsOf<User>;
// 结果包含: "name", "address", "address.street", "address.city", "address.geo", 
// "address.geo.lat", "address.geo.lng", "orders", "orders.id", "orders.items"...

2. 深度可选类型

typescript
// 将所有属性(包括嵌套属性)设为可选
type DeepPartial<T> = T extends object
  ? { [P in keyof T]?: DeepPartial<T[P]> }
  : T;

// 用例
interface Settings {
  theme: {
    light: {
      background: string;
      text: string;
    };
    dark: {
      background: string;
      text: string;
    };
  };
  notifications: {
    email: boolean;
    push: boolean;
    frequency: 'daily' | 'weekly' | 'monthly';
  };
}

// 用于表示部分设置更新
function updateSettings(settings: DeepPartial<Settings>) {
  // 实现省略...
}

// 可以只提供需要更新的部分属性
updateSettings({
  theme: {
    dark: {
      background: '#000'
    }
  }
});

3. 联合类型转交叉类型

typescript
// 将联合类型转换为交叉类型
type UnionToIntersection<U> = 
  (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;

// 用例
type Union = { a: string } | { b: number } | { c: boolean };
type Intersection = UnionToIntersection<Union>;
// 结果: { a: string } & { b: number } & { c: boolean }

// 实际应用:合并多个函数的参数类型
type MergeParameters<Funcs extends ((...args: any) => any)[]> = 
  UnionToIntersection<Funcs[number] extends (...args: infer Args) => any ? Args[0] : never>;

function compose<F extends ((...args: any) => any)[]>(...funcs: F) {
  return (params: MergeParameters<F>) => {
    // 实现省略...
  };
}

实践中的类型体操

在实际项目中,类型体操可以帮助我们解决很多复杂的类型问题。以下是一些实际场景:

1. API 类型自动生成

从 API 响应推导完整的类型:

typescript
// 从API响应推导类型
type InferAPIResponse<T> = 
  T extends Promise<infer R> 
    ? R extends { data: infer D } 
      ? D 
      : R 
    : never;

// 用例
async function fetchUserData(id: string) {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

type UserData = InferAPIResponse<ReturnType<typeof fetchUserData>>;

2. 类型安全的事件系统

实现类型安全的事件订阅:

typescript
// 类型安全的事件系统
type EventMap = {
  'user:login': { userId: string; timestamp: number };
  'user:logout': { userId: string; timestamp: number };
  'product:view': { productId: string; userId?: string };
  'cart:add': { productId: string; quantity: number; userId?: string };
};

// 类型安全的事件发布函数
function emit<E extends keyof EventMap>(event: E, data: EventMap[E]) {
  // 实现省略...
}

// 类型安全的事件订阅函数
function on<E extends keyof EventMap>(
  event: E, 
  handler: (data: EventMap[E]) => void
) {
  // 实现省略...
}

// 用例 - 类型检查完全工作
on('user:login', (data) => {
  console.log(`User ${data.userId} logged in at ${data.timestamp}`);
});

emit('product:view', { 
  productId: 'prod-123',
  userId: 'user-456'
});

// 类型错误
// emit('cart:add', { productId: 'prod-123' }); // 缺少 quantity 属性
// emit('user:login', { userId: 'user-123' }); // 缺少 timestamp 属性

3. 状态管理类型推导

为状态管理库提供精确的类型推导:

typescript
// Redux-like状态管理类型
type State = {
  user: {
    id: string | null;
    name: string | null;
    isLoggedIn: boolean;
  };
  products: {
    items: { id: string; name: string; price: number }[];
    loading: boolean;
    error: string | null;
  };
  cart: {
    items: { productId: string; quantity: number }[];
    total: number;
  };
};

type ActionMap = {
  'user/login': { id: string; name: string };
  'user/logout': undefined;
  'products/fetch': undefined;
  'products/fetchSuccess': { items: State['products']['items'] };
  'products/fetchError': { error: string };
  'cart/addItem': { productId: string; quantity: number };
  'cart/removeItem': { productId: string };
  'cart/clearCart': undefined;
};

// 类型安全的dispatch函数
function dispatch<T extends keyof ActionMap>(
  type: T,
  payload: ActionMap[T]
): void {
  // 实现省略...
}

// 类型安全的reducer
type Reducer<S, A extends keyof ActionMap> = 
  (state: S, action: { type: A; payload: ActionMap[A] }) => S;

// 用例
const userReducer: Reducer<State['user'], 'user/login' | 'user/logout'> = 
  (state, action) => {
    switch(action.type) {
      case 'user/login':
        return {
          id: action.payload.id,
          name: action.payload.name,
          isLoggedIn: true
        };
      case 'user/logout':
        return {
          id: null,
          name: null,
          isLoggedIn: false
        };
      default:
        return state;
    }
  };

// 使用dispatch - 类型安全
dispatch('user/login', { id: 'user-123', name: 'John' });
dispatch('cart/clearCart', undefined);

// 类型错误
// dispatch('user/login', { id: 123, name: 'John' }); // id 类型错误
// dispatch('cart/addItem', { productId: 'prod-123' }); // 缺少 quantity

总结

TypeScript 类型体操是一种强大的技术,它允许我们在编译时进行复杂的类型计算和转换。通过掌握这些技术,我们可以:

  1. 增强类型安全:在编译时捕获更多潜在错误
  2. 改善开发体验:提供更精确的自动完成和类型推导
  3. 实现高级类型抽象:构建可复用的类型工具
  4. 减少运行时代码:将一些逻辑提升到类型层面

虽然类型体操看起来很复杂,但在实际开发中,它们能够解决许多现实问题,特别是在大型复杂项目中。随着对 TypeScript 类型系统的深入了解,你会发现这些技术不仅仅是智力挑战,更是实用的开发工具。

最后的建议:类型体操虽然有趣且强大,但也要注意平衡。过于复杂的类型可能会导致代码难以理解和维护。始终根据实际需求选择合适的类型复杂度。