前端开发··2 阅读·预计 19 分钟

TypeScript 类型体操:掌握 8 个实战级高级类型技巧

在实际项目中,光靠 stringnumberboolean 是撑不住复杂业务的。这篇文章整理了 8 个我在真实项目中反复用到的高级类型技巧,每一个都有完整代码和实际场景。


1. DeepReadonly —— 递归冻结对象类型

React 项目中经常需要保证 props 的不可变性,浅层 Readonly 根本不够用:

type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? T[K] extends Function
      ? T[K]
      : DeepReadonly<T[K]>
    : T[K];
};

// 使用场景
interface AppConfig {
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
  features: {
    darkMode: boolean;
  };
}

type FrozenConfig = DeepReadonly<AppConfig>;

// 下面这行会报错:Cannot assign to 'host' because it is a read-only property
const config: FrozenConfig = {
  database: { host: "localhost", port: 5432, credentials: { username: "root", password: "xxx" } },
  features: { darkMode: true },
};
config.database.host = "127.0.0.1"; // ❌ 类型报错

关键点:用 T[K] extends Function 排除函数类型,避免把方法也变成只读。


2. 带类型安全的 EventEmitter

手写 EventEmitter 很简单,但加上类型约束后,IDE 能自动补全事件名和回调参数:

type EventMap = {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "order:created": { orderId: string; amount: number };
};

class TypedEmitter<TEvents extends Record<string, any>> {
  private handlers = new Map<string, Set<Function>>();

  on<K extends keyof TEvents>(event: K, handler: (payload: TEvents[K]) => void) {
    if (!this.handlers.has(event as string)) {
      this.handlers.set(event as string, new Set());
    }
    this.handlers.get(event as string)!.add(handler);
    return this;
  }

  emit<K extends keyof TEvents>(event: K, payload: TEvents[K]) {
    this.handlers.get(event as string)?.forEach((fn) => fn(payload));
    return this;
  }

  off<K extends keyof TEvents>(event: K, handler: Function) {
    this.handlers.get(event as string)?.delete(handler);
    return this;
  }
}

const emitter = new TypedEmitter<EventMap>();

// ✅ 类型安全:IDE 自动补全 "user:login" | "user:logout" | "order:created"
emitter.on("user:login", (data) => {
  console.log(data.userId, data.timestamp); // data 被推断为 { userId: string; timestamp: number }
});

// ❌ 编译报错:Argument of type '"user:sign"' is not assignable
// emitter.on("user:sign", () => {});

实战经验:这个模式在封装 WebSocket 客户端、跨组件通信、状态机事件时非常好用。


3. 深度合并两个对象类型(DeepMerge)

配置合并是常见需求,但类型层面的递归合并很多人搞不定:

type DeepMerge<T, U> = {
  [K in keyof T | keyof U]: K extends keyof U
    ? K extends keyof T
      ? T[K] extends object
        ? U[K] extends object
          ? DeepMerge<T[K], U[K]>
          : U[K]
        : U[K]
      : U[K]
    : K extends keyof T
      ? T[K]
      : never;
};

// 示例:合并默认配置和用户配置
interface DefaultConfig {
  theme: {
    primary: string;
    background: string;
  };
  editor: {
    fontSize: number;
    tabSize: number;
  };
}

interface UserOverride {
  theme: {
    primary: string;
    accent: string; // 用户新增字段
  };
}

type MergedConfig = DeepMerge<DefaultConfig, UserOverride>;
// 结果:
// {
//   theme: { primary: string; accent: string }; // U 覆盖 T(但丢掉了 background)
//   editor: { fontSize: number; tabSize: number };
// }

⚠️ 注意:上面的实现中,当两边都是对象时会递归合并,当 U 中某 key 对应的值不是对象时直接覆盖。如果需要更精确地保留 background,需要额外处理。


4. 类型安全的 API 响应处理

后端返回 { success: boolean; data?: T; error?: string } 时,用条件类型自动区分成功和失败:

type ApiResponse<T> =
  | { success: true; data: T }
  | { success: false; error: string };

interface User {
  id: string;
  name: string;
  email: string;
}

type UserResponse = ApiResponse<User>;

function handleUserResponse(response: UserResponse) {
  if (response.success) {
    // ✅ 这里 TypeScript 知道 response.data 存在
    console.log(response.data.name);
  } else {
    // ✅ 这里 TypeScript 知道 response.error 存在
    console.error(response.error);
  }
}

进阶用法:封装一个 fetch wrapper,让整个 API 层类型安全:

async function api<T>(url: string): Promise<ApiResponse<T>> {
  try {
    const res = await fetch(url);
    if (!res.ok) {
      return { success: false, error: `HTTP ${res.status}: ${res.statusText}` };
    }
    const data = await res.json();
    return { success: true, data };
  } catch (e) {
    return { success: false, error: (e as Error).message };
  }
}

// 使用
const userResult = await api<User>("/api/user/1");
if (userResult.success) {
  // userResult.data 类型是 User
  console.log(userResult.data.email);
}

5. Validate —— 编译期验证对象结构匹配接口

很多时候我们写了一个配置对象,但不确定它是否真的符合接口定义。可以用这种技巧在编译期强制检查:

type Validate<T, U extends T> = U;

interface CreateUserDTO {
  email: string;
  password: string;
  name?: string;
  age?: number;
}

// ✅ 编译通过
const validDTO = {
  email: "test@example.com",
  password: "secret123",
  name: "Tom",
} satisfies CreateUserDTO;

// ❌ 编译报错:缺少 email
// const invalidDTO = { password: "123" } satisfies CreateUserDTO;

satisfies 是 TS 4.9+ 引入的关键字,比 as 安全一万倍。它不做类型断言,而是做类型检查,同时保留字面量类型。


6. 函数参数类型提取 + 部分应用

在封装高阶函数、中间件时,经常需要提取和变换函数参数类型:

// 提取第 N 个参数的类型
type ParameterAt<T extends (...args: any) => any, N extends number> = Parameters<T>[N];

// 移除第一个参数(常用于包装已有函数)
type RemoveFirst<T extends any[]> = T extends [any, ...infer Rest] ? Rest : never;

// 实际应用:部分应用(partial application)
function partial<F extends (...args: any) => any, First extends any[]>(
  fn: F,
  ...firstArgs: First
): (...rest: RemoveFirst<Parameters<F>>) => ReturnType<F> {
  return (...restArgs) => fn(...firstArgs, ...restArgs);
}

// 示例
function createUser(name: string, age: number, role: string) {
  return { name, age, role };
}

const createAdmin = partial(createUser, "Admin", 30);
// createAdmin 的签名:(role: string) => { name: string; age: number; role: string }

const admin = createAdmin("superadmin");
// { name: "Admin", age: 30, role: "superadmin" }

7. 自动推断 API 路由参数类型

做后端框架类型封装时,路由参数的类型推断是个难点:

// 从 "/users/:id/posts/:postId" 提取出 { id: string; postId: string }
type ExtractParams<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? { [K in Param]: string } & ExtractParams<Rest>
    : T extends `${string}:${infer Param}`
      ? { [K in Param]: string }
      : {};

// 测试
type UserParams = ExtractParams<"/users/:id">;
// { id: string }

type PostParams = ExtractParams<"/users/:id/posts/:postId">;
// { id: string } & { postId: string } → { id: string; postId: string }

// 实际封装
interface RouteDefinition<TPath extends string> {
  path: TPath;
  method: "GET" | "POST" | "PUT" | "DELETE";
  handler: (params: ExtractParams<TPath>) => Promise<any>;
}

const getUserPosts: RouteDefinition<"/users/:id/posts/:postId"> = {
  path: "/users/:id/posts/:postId",
  method: "GET",
  handler: async (params) => {
    // params.id 和 params.postId 都有类型提示
    console.log(params.id, params.postId);
    return {};
  },
};

8. 分布式条件类型的陷阱与解决方案

这是最容易踩坑的地方。条件类型在联合类型上会「分配」,导致意想不到的结果:

// 陷阱:分布条件类型
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// 结果是 string[] | number[],而不是 (string | number)[]

// 解决方案:用 [] 包裹
type ToArrayNonDistributive<T> = [T] extends [any] ? T[] : never;
type Fixed = ToArrayNonDistributive<string | number>;
// 结果是 (string | number)[] ✅

什么时候需要关闭分配?

当你处理联合类型但不想自动展开时。比如把联合类型包装成数组,或者做整体比较时。

// 实际场景:判断一个类型是否是某个联合类型的一部分
type IsMember<T, Union> = [T] extends [Union] ? true : false;

type A = IsMember<"a", "a" | "b" | "c">; // true
type B = IsMember<"d", "a" | "b" | "c">; // false

总结

技巧核心用途
DeepReadonly递归不可变对象
TypedEmitter类型安全的事件系统
DeepMerge配置合并的类型安全
ApiResponse 联合类型条件类型窄化
satisfies编译期结构验证
Partial Application函数参数类型变换
路由参数推断字符串模板类型提取
关闭分配联合类型精确控制

这些不是花拳绣腿——我在中后台项目、组件库、Node.js 框架封装中反复用到它们。类型系统的价值在于:把运行时才能发现的错误,提前到编译期解决

下次写 as any 之前,先想想是不是可以用类型体操解决 😄

0 评论

评论区

登录 后参与评论