3.10 TypeScript 与类型检查 本节摘要:类型检查分两条路线:TypeScript 在编译期拦错,PropTypes 在运行时提醒。本节从 TypeScript 的基础类型讲起,重点落在 React 组件上——props 接口、可选属性、React.FC、常用类型工具——再用 PropTypes 补上运行时检查的视角,最后给出「大型项目为什么选 TypeScript」的选型判断。 读前必看(上) 阅读完本节,你应当能够: 用接口定义组件 props 的类型 使用可选属性、联合类型、字面量类型等核心类型语法 用 React.
本节摘要:类型检查分两条路线:TypeScript 在编译期拦错,PropTypes 在运行时提醒。本节从 TypeScript 的基础类型讲起,重点落在 React 组件上——props 接口、可选属性、React.FC、常用类型工具——再用 PropTypes 补上运行时检查的视角,最后给出「大型项目为什么选 TypeScript」的选型判断。
阅读完本节,你应当能够:
先看一个 JS 项目的日常:组件 A 传 props 给组件 B,B 假设 props.age 是数字。某天 A 忘了传 age,或者传了个字符串,B 渲染时炸了。JS 里这种错运行时才发现,而且往往在用户那边发现。
类型系统的核心价值是把「运行时错误」提前到「编写时发现」。TypeScript 在编译期检查类型,写错就报错,改完再跑——错误根本走不到用户面前。
SOURCE 原文把 TypeScript 定义为 JavaScript 的超集,添加静态类型定义。它不改变 JavaScript 的行为,只是让「类型约束」成为代码的一部分。
TypeScript 的编译流程:写代码 → tsc 检查 → 类型错误在编译期拦下 → 通过后编译成普通 JavaScript 再进浏览器。类型检查发生在编译阶段,所以它不改变运行行为,只是把「运行时才发现的错误」提前到「写代码时发现」。
let count: number = 5; let name: string = 'Alice'; let isDone: boolean = true; let ids: number[] = [1, 2, 3]; // 数组 let tuple: [string, number] = ['a', 1]; // 元组
TypeScript 的类型系统是「结构类型」——只要结构匹配,类型就匹配。这和后端语言的「名义类型」不同,写起来更灵活,也更符合 JS 的动态习惯。
描述对象用 interface:
interface Person { name: string; age: number; }
// 联合类型:值可以是字符串或数字 let id: string | number = 123; // 字面量类型:只能是指定的值 type Theme = 'light' | 'dark' | 'system'; let theme: Theme = 'light'; // 合法 theme = 'blue'; // 编译报错 // 类型别名:给复杂类型起名 type Status = 'pending' | 'done' | 'failed';
字面量类型和联合类型组合,是「枚举状态」的常用表达——比魔法字符串安全得多。
type Add = (x: number, y: number) => number; const add: Add = (x, y) => x + y;
这是 React + TS 的核心模式:
interface Props { name: string; age?: number; // 可选属性 } const MyComponent: React.FC<Props> = ({ name, age }) => { return ( <div> <h1>Hello, {name}!</h1> {age && <p>你 {age} 岁了。</p>} </div> ); };
interface Props 定义 props 的类型name: string 必传字符串age?: number 可选数字React.FC<Props> 泛型声明这是接收 Props 的函数组件使用组件时,TypeScript 检查传参:
<MyComponent name="Alice" age={30} /> // 正确 <MyComponent name={123} /> // 编译报错:name 必须是字符串
传错类型,编译期就拦住。这是 TS 在 React 里最大的价值——组件之间的契约在编译期检查。
React.FC 是老写法,现在官方建议函数组件直接用普通函数声明:
function MyComponent({ name, age }: Props) { return <div>...</div>; }
两种都能用,功能等价。React.FC 隐式支持 children,普通函数需要显式声明。团队统一一种即可,关键是 props 类型不能少。
interface User { id: number; name: string; } interface TodoItemProps { todo: User; onToggle: (id: number) => void; // 函数类型 tags?: string[]; // 可选数组 }
函数作为 props 时,类型要写清楚参数和返回值。(id: number) => void 表示「接收数字、不返回任何值」——漏传参数或返回类型不符都会报错。
TS 内置了一批操作类型的工具,React 里高频使用:
interface User { id: number; name: string; email: string; age: number; } type PartialUser = Partial<User>; // 所有属性可选 type ReadonlyUser = Readonly<User>; // 所有属性只读 type UserIDName = Pick<User, 'id' | 'name'>; // 只选 id 和 name type UserNoEmail = Omit<User, 'email'>; // 排除 email type StringMap = Record<string, number>; // 键字符串、值数字的对象
| 工具 | 作用 | 典型场景 |
|---|---|---|
| Partial | 所有属性变可选 | 表单编辑的草稿状态 |
| Required | 所有属性必选 | 补全可选字段 |
| Pick<T, K> | 挑出部分属性 | 列表项只需 id/name |
| Omit<T, K> | 排除部分属性 | 创建对象排除 id |
| Record<K, T> | 键值对对象 | 枚举映射 |

两张通道各管一段:TypeScript 在编译期全面拦截,PropTypes 在运行时针对 props 提醒。渐进迁移的项目可以短期双写——TS 内部约束、PropTypes 对外兜底。
泛型让「类型」也能像参数一样被传入。React 里最熟悉的泛型是 useState——它的类型由初始值推断,也可以显式指定:
const [user, setUser] = useState<User | null>(null); // setUser 只能接收 User 或 null,传字符串会报错
自定义函数也可以用泛型,让工具函数适配多种类型:
function getFirst<T>(arr: T[]): T | undefined { return arr[0]; } const n = getFirst([1, 2, 3]); // n 的类型是 number const s = getFirst(['a', 'b']); // s 的类型是 string
理解泛型的关键是「类型参数」:它在调用时被具体类型替换,既保证类型安全又保持复用性。写通用工具、可复用组件时,泛型是必需品。
联合类型(string | number)在使用前要先收窄——判断具体是哪个:
function format(value: string | number): string { if (typeof value === 'number') { return value.toFixed(2); // 收窄为 number } return value.toUpperCase(); // 收窄为 string }
typeof 判断后,TS 在分支内自动收窄类型。React 里处理「可能为 null 的数据」也是这个模式:if (user) { user.name },判空即收窄。类型收窄是 TS 日常里最常见的写法,掌握了它,null 检查、分支处理就不再报错了。
TS 推断不出正确类型时,用 as 断言:
const element = document.getElementById('my-input') as HTMLInputElement; if (element) { element.value = 'Hello'; }
断言是「我比你懂」的声明,会绕过类型检查,谨慎使用。能用类型收窄(条件判断)就不用断言。
⚠️ 常见坑:滥用 as 把类型问题「藏起来」。
as unknown as X双断言更是双倍危险——它彻底关闭了类型保护。断言是逃生门,不是日常通道。
PropTypes 是 React 内建的运行时检查机制,纯 JS 项目也能用。它不参与编译,而是在开发模式下运行时检查 props 类型,不匹配就打警告。
import PropTypes from 'prop-types'; const MyComponent = ({ name, age }) => { return ( <div> <h1>Hello, {name}!</h1> {age && <p>你 {age} 岁了。</p>} </div> ); }; MyComponent.propTypes = { name: PropTypes.string.isRequired, age: PropTypes.number, };
传错类型,控制台警告:
<MyComponent name={123} /> // 开发模式控制台警告:name 应为 string
PropTypes 的定位是「低成本入口」——不需要编译、不改变构建流程,纯 JS 项目贴上去就能用。它也适合给「从 JS 迁移到 TS 的路上」的项目临时兜底,边迁边用 PropTypes 提醒遗留问题。
| 维度 | TypeScript | PropTypes |
|---|---|---|
| 检查时机 | 编译期 | 运行时(开发模式) |
| 是否需编译 | 需要 | 不需要 |
| 类型覆盖面 | 全面(变量、函数、结构) | 仅 props |
| 性能 | 无运行时开销 | 开发模式有少量开销 |
| 学习成本 | 高 | 低 |
| 适用 | 中大型项目 | 小型项目、渐进接入 |
新项目、中大型项目、团队协作项目:直接用 TypeScript。 它带来的不只是类型检查——编辑器补全、重构安全、文档即代码,这些收益在项目变大后是刚需。
存量 JS 项目、小型项目、快速原型:TypeScript 可选,PropTypes 起步。 渐进迁移到 TS 的路径也成熟:先装 typescript 开启检查,再逐步给文件加类型。
一个诚实的提醒:TypeScript 有学习成本,短期内「写代码变慢了」。这个慢是「写的时候」慢,「改的时候」快——类型让重构成为受保护的行为。长期看,这笔投入在项目超过某个规模后回收得特别快。
「interface 和 type 到底该用哪个?」 这是 TypeScript 社区最常被问的问题。两者大部分场景可以互换,但有几处实质差异值得记住:interface 可以声明合并(同名 interface 会自动合并,方便扩展第三方库的类型)、可以继承(extends);type 支持联合类型、交叉类型、映射类型这些「类型运算」,interface 做不到。社区通行约定是「描述对象形状用 interface,需要类型运算用 type」——React 的 props 通常用 interface 就够了。关键是别纠结,两者混用也不会出问题,团队约定一种主用即可。
「为什么用了 TS 还是报『对象可能是 null』?」 因为 strict 模式下,TS 默认 null 和 undefined 不会被自动排除。document.getElementById 返回 HTMLElement | null,你不判空就访问 .value,TS 必须拦你——这是它「逼你处理真实边界」的设计。解法不是关 strict,而是学会判空、可选链、非空断言这组工具:if (el) 判空、el?.value 可选链、el!.value 非空断言(慎用)。习惯了之后你会发现,这些报错大多数时候「报得对」——真的有一类线上 bug 就是「元素还没挂载就访问了」。
「React.FC 到底建不建议用?」 现状是「历史推荐、官方不再推荐」。React.FC 的好处是隐式带 children 类型;但它的坏处也是隐式——你没法在类型里表达「这个组件不接受 children」,而且函数组件如今建议直接用普通函数声明(官方文档已改为这种示例)。结论:新代码用普通函数声明 + 显式 Props 接口,遇到旧代码里的 React.FC 能读懂即可,不必急着全量重写——它不影响运行,只是类型风格问题。
「PropTypes 和 TS 同时用会不会冲突?」 不会,但一般是重复劳动。TS 在编译期检查类型,PropTypes 在运行时检查,两者是「编译期与运行时」的两个保险。实际项目中「TS + PropTypes 双写」很罕见——TS 已经覆盖了 PropTypes 的大部分场景,PropTypes 的价值主要在「纯 JS 项目」或「第三方组件使用方没有 TS」时。渐进迁移场景里,双写可以短期共存:TS 在内部约束,PropTypes 对外部使用方兜底,迁移完成后撤掉 PropTypes。这个「过渡期双保险」的思路,比「一步到位全换」稳妥。
💡 关键直觉:类型系统是「契约的显式化」。props 的类型定义,等于告诉所有使用方「这个组件需要什么、返回什么」。TypeScript 让这个契约在编译期强制,PropTypes 让它运行时提醒。选哪个取决于你要「多早发现错误」。
strict 模式必开。tsconfig 里 strict: true 开启最严格的检查,少踩「类型好像没检查到」的坑。开 strict 初期会有大量报错,但修完就一劳永逸。
any 是最后手段。any 关闭了类型检查,等于把 TS 降级回 JS。能用 unknown + 收窄,就别用 any。
类型定义单独组织。共享的接口、类型别名集中放,避免散落在组件文件里互相 import。
Hooks 也有类型。useState 的类型由初始值推断,useRef 要显式给类型(useRef(null))。不写对 ref 类型,访问 current 时会因为 null 报错。
几个高频 Hook 的类型化写法,一次性给全:
import { useState, useRef, useEffect } from 'react'; // useState:初始值推断,或显式泛型 const [count, setCount] = useState(0); // number const [user, setUser] = useState<User | null>(null); // useRef:必须给类型,因为初始是 null const inputRef = useRef<HTMLInputElement>(null); inputRef.current?.focus(); // 可选链,处理 null // useEffect:依赖数组照常 useEffect(() => { // 副作用 }, [user]);
useRef 的 null 是 TS 里的经典难点:ref.current 在首次渲染时是 null,访问属性要可选链(?.)或判空。写成 useRef<HTMLInputElement>(null) 后,TS 会强制你在用之前处理 null——这正是类型系统的价值:它逼你处理「元素可能还没挂载」的真实情况。
新手接 TS 最常碰到的几类报错,认识它们能省很多时间:
| 报错 | 原因 | 解法 |
|---|---|---|
| name 类型不匹配 | props 传错类型 | 检查 interface 定义 |
| current 可能为 null | ref 未判空 | 可选链 ?. 或判空 |
| 对象可能为 undefined | 解构可选属性 | 给默认值或判空 |
| 函数返回类型不符 | 回调签名不对 | 按类型声明补全参数 |
| implicit any | 变量没类型 | 显式标注或让 TS 推断 |
绝大多数 TS 报错不是「类型写错」,而是「TS 在提醒你有真实的边界情况没处理」——对象可能是空、ref 可能是 null。把报错当成提示,而不是麻烦。
工程里 TS 的「类型管理」还有个组织问题:类型定义放哪。三种常见位置各有适用场景。第一,「内联定义」——类型只在单个组件内使用,直接写在组件文件里,就近、简单,适合私有类型。第二,「集中导出」——跨组件共享的类型(Props、数据模型、接口返回结构)集中放在类型模块里,统一导出,避免「每个组件各定义一份同名 Props」导致的漂移。第三,「声明文件」——给纯 JS 的第三方库补类型、或定义全局类型,用声明文件(.d.ts)。三个位置的判断标准是「复用范围」:只自己用的内联,多人用的集中,库级的进声明文件。类型组织得清楚,改数据模型时只需要动一处,其余靠编译检查定位引用——这是 TS 在「重构安全」上最直接的收益。
下一章进入生态系统与工具——TypeScript 让代码可靠,工具链让项目高效。脚手架、状态库生态、UI 组件库、CSS 方案、常用工具,第 4 章把这些选择全部摊开,给你一套有依据的选型清单,第 3 章学到的所有选型逻辑都会在这里落地。