第 9 章

映射类型:变换对象结构,实现 DeepPartial

第9章:映射类型:变换对象结构,实现 DeepPartial

理解映射类型是掌握 TypeScript 类型系统的关键一步。

本章核心问题:如何在实际项目中正确使用变换对象结构,实现 DeepPartial?关键的设计决策和陷阱是什么?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

映射类型的语法核心

映射类型让你基于已有类型批量生成新类型,就像对象的 .map() 方法——但作用于类型层面。

// 基础语法:遍历 T 的每个键,生成新类型
type Copy<T> = {
  [K in keyof T]: T[K];
};

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

type UserCopy = Copy<User>;
// 等价于:{ id: number; name: string; email: string }

K in keyof T 是核心——keyof T 产生 T 所有键的联合类型,in 遍历它们。

修饰符:添加和移除 readonly / ?

映射类型可以给每个属性添加或移除 readonly 和可选标记 ?

// 添加 readonly(+readonly 等同于 readonly)
type Immutable<T> = {
  +readonly [K in keyof T]: T[K];
};

// 移除 readonly(解冻类型)
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

// 添加可选标记
type Optional<T> = {
  [K in keyof T]+?: T[K];
};

// 移除可选标记(所有字段变为必填)
type Required<T> = {
  [K in keyof T]-?: T[K];
};

实际对比:

interface Config {
  readonly host: string;
  port?: number;
  timeout: number;
}

type MutableConfig = Mutable<Config>;
// { host: string; port?: number; timeout: number }
// host 不再是 readonly

type AllRequired = Required<Config>;
// { host: string; port: number; timeout: number }
// port 不再是可选

as 重映射键名

TypeScript 4.1 引入了 as 子句,允许在映射时变换键名。

// 把所有键转为 getter 方法名
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

interface Person {
  name: string;
  age: number;
}

type PersonGetters = Getters<Person>;
// {
//   getName: () => string;
//   getAge:  () => number;
// }

string & K 是必要的——K 的类型是 string | number | symbol,而 Capitalize 只接受 string,取交集后过滤掉非字符串键。

as ... never 过滤键

as 子句返回 never 时,该键从结果类型中消失。

// 只保留值为特定类型的键
type PickByValue<T, V> = {
  [K in keyof T as T[K] extends V ? K : never]: T[K];
};

interface Mixed {
  id: number;
  name: string;
  active: boolean;
  score: number;
}

type StringKeys = PickByValue<Mixed, string>;
// { name: string }

type NumberKeys = PickByValue<Mixed, number>;
// { id: number; score: number }

Level 2 · 它是怎么运行的(3-5年经验)

实用映射类型示例

Nullable<T>:所有字段加 null

type Nullable<T> = {
  [K in keyof T]: T[K] | null;
};

type NullableUser = Nullable<User>;
// { id: number | null; name: string | null; email: string | null }

EventMap<T>:从接口生成事件名

interface AppEvents {
  userLogin: { userId: string };
  dataLoaded: { count: number };
  errorOccurred: { message: string };
}

type EventNames<T> = {
  [K in keyof T as `on${Capitalize<string & K>}`]: (payload: T[K]) => void;
};

type AppHandlers = EventNames<AppEvents>;
// {
//   onUserLogin:     (payload: { userId: string }) => void;
//   onDataLoaded:    (payload: { count: number })  => void;
//   onErrorOccurred: (payload: { message: string }) => void;
// }

主项目:实现 DeepPartial<T>

标准库的 Partial<T> 只有一层——嵌套对象不受影响。

interface FormData {
  user: {
    name: string;
    address: {
      city: string;
      zip: string;
    };
  };
  settings: {
    theme: "light" | "dark";
    notifications: boolean;
  };
}

// 标准 Partial — 只有顶层变为可选
type ShallowPartial = Partial<FormData>;
// {
//   user?: {
//     name: string;      ← 内层仍然必填!
//     address: { city: string; zip: string; }
//   };
//   settings?: { theme: ...; notifications: boolean; }
// }

第一步:识别递归场景

// 错误做法:无限递归无终止条件
type BadDeepPartial<T> = {
  [K in keyof T]?: BadDeepPartial<T[K]>; // 基础类型也被递归,破坏 string/number
};

第二步:正确实现

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

T extends object 是关键:只有对象类型才递归,stringnumberboolean 等直接返回自身。

第三步:验证

type PartialFormData = DeepPartial<FormData>;

// 合法:只更新深层字段
const patch: PartialFormData = {
  user: {
    address: {
      city: "Shanghai", // zip 可以省略
    },
    // name 也可以省略
  },
};

// API patch 请求的典型用法
async function updateForm(id: string, patch: DeepPartial<FormData>) {
  const response = await fetch(`/api/forms/${id}`, {
    method: "PATCH",
    body: JSON.stringify(patch),
  });
  return response.json();
}

处理数组

// 数组中的元素也需要递归
type DeepPartialWithArray<T> = T extends Array<infer Item>
  ? Array<DeepPartialWithArray<Item>>
  : T extends object
  ? { [K in keyof T]?: DeepPartialWithArray<T[K]> }
  : T;

interface Catalog {
  products: Array<{
    id: number;
    name: string;
    specs: { weight: number; color: string };
  }>;
}

type PatchCatalog = DeepPartialWithArray<Catalog>;
// products 变为 Array<{ id?: number; name?: string; specs?: { weight?: number; color?: string } }>

主项目:实现 DeepReadonly<T>

type DeepReadonly<T> = T extends Array<infer Item>
  ? ReadonlyArray<DeepReadonly<Item>>
  : T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

interface AppState {
  user: {
    id: string;
    preferences: {
      language: string;
      theme: string;
    };
  };
  cache: Map<string, unknown>;
}

type FrozenState = DeepReadonly<AppState>;

declare const state: FrozenState;
// state.user.preferences.language = "zh"; // 错误:只读
// state.user = { ... };                    // 错误:只读

对比表

操作 语法 效果
添加可选 [K in keyof T]?: 所有字段变可选
移除可选 [K in keyof T]-?: 所有字段变必填
添加只读 readonly [K in keyof T]: 所有字段只读
移除只读 -readonly [K in keyof T]: 取消只读限制
重命名键 [K in keyof T as NewKey]: 键名变换
过滤键 [K in keyof T as ... ? K : never]: 按条件删除键
递归变换 T extends object ? {...} : T 深层遍历

Level 3 · 规范怎么定义的(资深)

映射类型的 as 子句(TS 4.1)是键重映射(key remapping)的核心,它允许在遍历键时变换键名或过滤键。当 as 子句返回 never 时,该键被移除——这利用了 never 在映射类型中的消除语义。DeepPartial<T> 的递归终止条件 T extends object 至关重要:基础类型(string、number 等)不应被递归处理,否则会破坏它们的结构。数组需要特殊处理(T extends Array<infer Item>),因为数组也是 object。

Level 4 · 边界与陷阱(所有人)

反模式:过度使用映射类型

// ❌ 反模式:用映射类型做本可以用 Pick 完成的事
type ComplexPick<T, K extends keyof T> = {
  [P in K]: T[P]; // 等同于标准库 Pick<T, K>,没有任何好处
};

// ✓ 直接使用 Pick
type UserPreview = Pick<User, "id" | "name">;

// ❌ 反模式:映射类型嵌套三层以上,可读性崩溃
type OverEngineered<T> = {
  [K in keyof T as K extends string
    ? `__${Uppercase<K>}__`
    : never]: T[K] extends object
    ? { [P in keyof T[K]]: T[K][P] extends string ? `[${T[K][P]}]` : T[K][P] }
    : T[K];
};
// 如果真的需要这种变换,分成多个命名类型更易读

本章小结

概念 关键点
[K in keyof T] 遍历所有键,是映射类型的基础
+? / -? 添加或移除可选标记
-readonly 移除只读限制(标准库 Mutable 的实现方式)
as NewKey 映射时重命名键,返回 never 则删除该键
DeepPartial<T> 递归映射 + extends object 做基础类型保护
DeepReadonly<T> 数组用 ReadonlyArray,对象递归加 readonly

下一章预告

第 10 章讲模板字面量类型——把字符串运算搬进类型层面。你将看到如何从路由字符串 "/users/:id/posts/:postId" 中自动提取参数类型,以及如何构建类型安全的 i18n 键系统。

本章评分
4.7  / 5  (38 评分)

💬 留言讨论