TypeScript 进阶:类型体操与项目实践

0 0

前言

做前端架构的人要不要精 TypeScript?我的回答是 ——不是去推导 TypeScript Compiler 源码,而是建立”用类型把运行时错误挡在编译期”的工程直觉。原因有三:

  1. 大型项目靠 TS 守门。800+ 页面的 ERP 不用 TS,每加一个字段就是 P0 bug 风险;用 TS,80% 的字段拼写错误、null 漏处理、API 字段类型漂移tsc 阶段就拦下来。
  2. 类型体操不是炫技,是抽象工具Partial<T> / Pick<T, K> / ReturnType<typeof fn> 这些内置类型就是设计的复用单元;自己写条件类型 + infer 是构建框架 / SDK 的必备能力。
  3. tsconfig 配置直接决定开发体验strictnoUncheckedIndexedAccessexactOptionalPropertyTypes 这些开关要不要开,决定了团队写代码的严谨程度。

这一篇是『前端架构修仙路』的第 6 篇。我跳过 TypeScript 编译器实现(不展开 AST / checker),用实战案例 + 类型逐步推演 + 配置决策表,把泛型 / 条件类型 / infer / 模板字面量类型讲透。下一篇进入筑基期,聊前端框架演变与 MVVM 思想史。

一、为什么架构师必须用 TypeScript:来自生产的真实数据

简历里所有 ERP / FinUI / Electron 项目都重度依赖 TypeScript。为什么不用 JS?三个对比:

维度JavaScriptTypeScript
字段拼写错误运行时才发现(线上 P0)编译时拦截
重构改名全靠 grep + 测试编译器告诉你所有引用点
第三方 API 类型靠文档 + JSDoc.d.ts 自动提示
团队协作口头约定类型即文档
长期维护越改越乱类型网让代码”自我约束”

生产案例:顺丰 ERP 200+ 表格列定义,迁移到 TS 后字段拼写错误率从 0.5% 降到 0——因为每个 <TableColumn prop="xxx" />xxx 必须在 Columns 类型里声明过。

二、TypeScript 类型系统全景

图 1:TS 类型系统的 4 层

越往上越强大,但也越抽象。架构师必须掌握前 3 层 + 第 4 层的核心招式

三、泛型:从”any” 到”类型参数化”

3.1 基础泛型:把”重复”变成”参数”

// ❌ 反模式:每个类型写一遍
function identityString(arg: string): string { return arg; }
function identityNumber(arg: number): number { return arg; }
function identityBoolean(arg: boolean): boolean { return arg; }

// ✅ 推荐:泛型
function identity<T>(arg: T): T { return arg; }
const a = identity<string>('hello');    // T = string
const b = identity(42);                  // T 推断为 number

3.2 泛型约束:extends

// T 必须有 length 属性
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

longest('hello', 'hi');     // ✅ string 有 length
longest([1, 2], [3, 4, 5]); // ✅ array 有 length
longest(123, 456);          // ❌ number 没有 length

3.3 泛型在 React 中的杀手场景:组件 Props

// 受控组件的泛型 props
interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
  keyExtractor: (item: T) => string;
}

function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
  return (
    <ul>
      {items.map(item => (
        <li key={keyExtractor(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

// 使用
<List<User>
  items={users}
  renderItem={u => <span>{u.name}</span>}
  keyExtractor={u => u.id}
/>

T 一旦从 items 推断为 UserrenderItemkeyExtractor 都自动拿到 User 类型——这是 plain React 写法做不到的。

四、条件类型:if-else 在类型系统里

4.1 基础:T extends U ? X : Y

type IsString<T> = T extends string ? true : false;

type A = IsString<string>;   // true
type B = IsString<number>;   // false
type C = IsString<'hello'>;  // true (literal types extend string)

4.2 实战:API 响应类型转换

// 后端返回可能是 user 也可能是 error,我们想自动提取 data 类型
type ApiResponse<Data, Error = string> = {
  code: number;
  data?: Data;
  error?: Error;
};

// 自动提取 data 字段类型
type ExtractData<T> = T extends ApiResponse<infer D, any> ? D : never;

type UserResp = ApiResponse<{ id: number; name: string }>;
type User = ExtractData<UserResp>;  // { id: number; name: string }

4.3 infer:在条件类型里声明局部变量

infer 是条件类型的”杀手特性”——可以从泛型里推断并捕获子类型。

// 提取函数返回值类型
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function fetchUser() { return { id: 1, name: 'Alice' }; }
type User = MyReturnType<typeof fetchUser>;  // { id: number; name: string }

// 提取 Promise 内部类型
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type A = UnwrapPromise<Promise<string>>;  // string
type B = UnwrapPromise<number>;           // number

// 提取数组元素类型
type ElementOf<T> = T extends (infer E)[] ? E : never;
type E = ElementOf<string[]>;  // string

架构师心得infer 让你在不引入额外类型参数的情况下,从一个复杂类型里提取子类型。这是写框架 / SDK / 通用工具的核心武器。

4.4 实战:组件 Props 的 as 模式

// <Button as="a"> 会改变 Button 的 props 类型
type AsProps<El extends keyof JSX.IntrinsicElements> = {
  as?: El;
} & JSX.IntrinsicElements[El];

function Button<E extends keyof JSX.IntrinsicElements = 'button'>(
  props: AsProps<E>
) { /* ... */ }

// 使用
<Button>click</Button>                          // props: button props
<Button as="a" href="/home">home</Button>        // props: anchor props,自动补全 href

五、映射类型:批量转换对象类型

5.1 基础:{ [K in keyof T]: ... }

// 把所有字段变成 readonly
type ReadonlyAll<T> = { readonly [K in keyof T]: T[K] };

type User = { id: number; name: string };
type FrozenUser = ReadonlyAll<User>;
// { readonly id: number; readonly name: string }

// 把所有字段变成可选
type OptionalAll<T> = { [K in keyof T]?: T[K] };
type PartialUser = OptionalAll<User>;  // { id?: number; name?: string }

5.2 内置映射类型(TS 自带)

Partial<T>           // 所有字段可选 ({ [K]?: T[K] })
Required<T>          // 所有字段必填 ({ [K]-?: T[K] })
Readonly<T>          // 所有字段只读 ({ readonly [K]: T[K] })
Pick<T, K>           // 挑字段 ({ [P in K]: T[P] })
Omit<T, K>           // 排除字段 (Pick<T, Exclude<keyof T, K>>)
Record<K, V>         // 索引签名 ({ [P in K]: V })
NonNullable<T>       // 排除 null/undefined

生产实战

// 表格列定义,只暴露部分字段给后端
interface FullUser {
  id: number;
  name: string;
  email: string;
  passwordHash: string;  // 不该给前端
}

// 传给后端的 DTO,排除敏感字段
type UserDTO = Omit<FullUser, 'passwordHash'>;

// React props 派生子集
type UserCardProps = Pick<FullUser, 'id' | 'name'>;

六、模板字面量类型:TS 4.1+ 的字符串级类型

6.1 字符串拼接类型

// 事件名自动拼接
type EventName = `on${Capitalize<string>}`;  // 'onClick' | 'onChange' | ...

// API 路径自动拼接
type ApiPath = `/api/${string}`;  // 任何 /api/ 开头的路径

// CSS 单位拼接
type CssUnit = `${number}${'px' | 'rem' | 'em' | '%'}`;
const width: CssUnit = '100px';    // ✅
const height: CssUnit = '5rem';   // ✅
const invalid: CssUnit = '5vw';  // ❌

6.2 实战:类型安全的 i18n key

type Locale = 'zh' | 'en' | 'ja';
type Namespace = 'common' | 'blog' | 'admin';
type I18nKey = `${Namespace}.${string}`;

const t = (key: I18nKey, locale: Locale) => { /* ... */ };
t('common.welcome', 'zh');  // ✅
t('common.', 'zh');        // ❌ TS 报错: 字符串不能为空
t('home.welcome', 'zh');   // ❌ TS 报错: namespace 必须是 common/blog/admin

FinUI 项目实战:通过模板字面量类型 + 自动生成脚本,把 i18n key 的拼写错误率从 2% 降到 0。

七、tsconfig 关键开关:架构师的纪律

{
  "compilerOptions": {
    "strict": true,                  // 开启所有 strict 选项
    "noUncheckedIndexedAccess": true, // arr[i] 默认是 T | undefined
    "exactOptionalPropertyTypes": true, // { x?: string } 不能赋 undefined
    "noImplicitOverride": true,      // 子类 override 必须显式标注
    "noFallthroughCasesInSwitch": true,
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "isolatedModules": true,         // 每个文件单独编译
    "skipLibCheck": true,             // 跳过 .d.ts 检查,加速
    "verbatimModuleSyntax": true,    // 强制 import type 标注
    "target": "ES2022",                // 最低目标
    "moduleResolution": "Bundler",    // Vite/Webpack 风格
    "module": "ESNext",
    "lib": ["DOM", "DOM.Iterable", "ES2022"]
  }
}

架构师决策表

开关新项目老项目理由
strict✅ 必开✅ 必须开基础纪律
noUncheckedIndexedAccess✅ 必开⚠️ 看迁移成本防止数组越界
exactOptionalPropertyTypes✅ 推荐开❌ 老项目慎开严格区分可选 vs undefined
noUnusedLocals✅ 推荐开减少死代码
skipLibCheck✅ 必开不开 build 慢 5-10 倍
verbatimModuleSyntax✅ 推荐开强制 type-only import

生产案例:iView 早期 JS 项目迁移到 TS 后,第一周开了 strict + noUncheckedIndexedAccess,暴露了 200+ 隐藏 bug;用 2 周改完,整个项目稳定性从 95% 升到 99.5%。

八、模式:Discriminated Union(可辨识联合类型)

架构师最该掌握的 TS 模式——比 class hierarchy 更适合表达状态:

// ❌ 反模式:可选字段散落
interface ApiResult {
  loading?: boolean;
  data?: User;
  error?: Error;
}
if (result.data) {  // 可能 data 是 undefined
  // ...
}

// ✅ 推荐:discriminated union
type ApiResult =
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; error: Error };

function handle(result: ApiResult) {
  switch (result.status) {
    case 'loading':
      // result: { status: 'loading' }
      break;
    case 'success':
      // result: { status: 'success'; data: User } — TS 自动收窄
      console.log(result.data.name);
      break;
    case 'error':
      // result: { status: 'error'; error: Error }
      console.error(result.error);
      break;
  }
}

生产实战:FinUI 所有异步组件 props 用 discriminated union;3 年项目里没有出现过 “data 是 undefined” 类的运行时崩溃。

九、踩坑提醒(资深架构师请重点看)

  1. 不要滥用 anyany 让 TS 失去意义——tsconfig"noImplicitAny": true 是底线。
  2. 不要用 as 强行断言as 是绕过 TS 的”逃生舱口”——能不用就不用。真正的解决方案是补类型定义,而不是 as unknown as User
  3. 不要让 enum 出现在跨包边界enum 编译后是反向映射对象,跨包使用时容易出 bug。改用 as const + 字面量联合类型
  4. 不要混用 interfacetype 做同样的事。团队要约定:interface 只用于 React props / 公共 API(可扩展),type 用于类型运算(不可扩展)。
  5. 不要忽略 TS 编译错误。“项目能跑就行” 是反模式——TS 错误的每一次累积,都让未来的重构成本翻倍

总结

这一篇用 9 个关键事实把 TypeScript 进阶串起来:

  • TS 是大型项目必备:字段错误率从 0.5% → 0,重构命名 100% 找出所有引用。
  • 泛型 + 约束 + extends 是类型参数化的基石。
  • 条件类型 + infer 让你在类型系统里做”if-else”,是写框架的杀手武器。
  • 映射类型(Partial / Pick / Omit)是批量转换对象的标准工具。
  • 模板字面量类型让字符串级类型安全(CSS 单位、API 路径、i18n key)。
  • tsconfig 严格开关决定团队代码的严谨基线。
  • Discriminated Union 替代 class hierarchy 表达状态。
  • any / as / enum 是三大毒药,团队纪律要明确禁用。

下一篇:筑基期开篇——前端框架演变与 MVVM 思想史,从 Backbone / Knockout 到 React / Vue / Angular 的演化逻辑。

5 道重点面试问题方向

Q1(答案):TypeScript 的泛型、条件类型、infer 关键字分别是什么?请给一个实际项目里使用它们的例子。

A泛型是类型参数化——<T> 让函数 / 接口 / 类接受任意类型而不丢类型信息。条件类型 T extends U ? X : Y 是类型层面的 if-else,常用于类型分发和映射。infer 在条件类型里声明局部变量,从泛型里捕获子类型——典型用法是提取函数返回值(ReturnType<T>)、Promise 内部类型(Awaited<T>)、数组元素类型。实战案例:React 组件 <List<T>> 用泛型让 items / renderItem / keyExtractor 类型联动;框架的”组件 props 根据 as 属性变化”用 inferkeyof JSX.IntrinsicElements 提取对应元素 props。

Q2(思考):TypeScript 的 enum 有什么坑?为什么推荐用 as const + 字面量联合类型替代?

Q3(思考):TypeScript 的 Discriminated Union(可辨识联合类型)相比 class hierarchy 有什么优势?请用 API loading/success/error 状态举个例子。

Q4(思考)noUncheckedIndexedAccess 这个 tsconfig 选项打开后会产生什么效果?为什么大型项目应该开?

Q5(思考):你在 800+ 页面的 ERP 项目里是怎么推广 TypeScript 的?怎么让团队从 JS 迁移到 TS 不破坏节奏?

💡 每道题后面都有 AI 助手按钮,可以一键拿到详细答案。

参考资料

🔗 原文链接 分享让更多人看到

评论