学完这篇,你能把 React 项目里那些随手写的 any、到处 as 断言、以及复制粘贴的接口定义清理干净,并写出一个真正能被类型推导出来的泛型组件。下面按顺序走一遍。
第一步:先把 tsconfig 的 strict 打开
所有类型技巧的前提是编译器愿意帮你检查。打开 tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"jsx": "react-jsx"
}
}
strict 一次性打开 noImplicitAny、strictNullChecks 等一整套开关。老项目可以先开 strict,再逐步加后两项。
注意:noUncheckedIndexedAccess 会让 arr[0] 的类型变成 T | undefined,如果你有大量数组下标访问,先评估工作量再开。
第二步:Props 用 interface,联合类型用 type
日常约定:对象形状(组件 props、接口响应体)用 interface,联合类型、工具类型组合用 type。
interface ButtonProps {
variant: 'primary' | 'ghost';
size?: 'sm' | 'md';
children: React.ReactNode;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}
export function Button({ variant, size = 'md', children, onClick }: ButtonProps) {
return (
<button className={`btn-${variant} btn-${size}`} onClick={onClick}>
{children}
</button>
);
}
三个细节值得记住:
children 用 React.ReactNode,别用 JSX.Element,否则字符串、数组、null 都传不进来。
- 事件类型写全
React.MouseEvent<HTMLButtonElement>,直接照抄 DOM 元素名,不接受 any。
- 可选 props 用
? 而不是 | undefined 混写,配合 exactOptionalPropertyTypes 时行为更一致。
注意:不要用 React.FC<Props>。它在旧版本里隐式带上 children,还吞掉了泛型能力,直接写普通函数声明最省事。
第三步:把 hook 的类型一次性配对
useState 大多能自动推导,但遇到联合类型或对象初始值为空时要显式给:
// 空数组起步,需要显式声明元素类型
const [items, setItems] = useState<Item[]>([]);
// 联合类型,初始值推不出完整范围
const [status, setStatus] = useState<'idle' | 'loading' | 'done'>('idle');
// DOM 引用
const inputRef = useRef<HTMLInputElement>(null);
// useReducer 让 action 保持可辨识联合
type Action =
| { type: 'add'; payload: Item }
| { type: 'remove'; id: string };
useRef<HTMLInputElement>(null) 的返回值是 RefObject<HTMLInputElement | null>,用之前判空即可。
第四步:写你第一个泛型组件
需求:一个下拉列表,options 是任意对象数组,用户拿到的是原始对象而不是字符串。
interface SelectProps<T> {
options: T[];
value: T | null;
getLabel: (item: T) => string;
onChange: (item: T) => void;
}
export function Select<T>({ options, value, getLabel, onChange }: SelectProps<T>) {
return (
<select
value={value ? getLabel(value) : ''}
onChange={(e) => {
const next = options.find((o) => getLabel(o) === e.target.value);
if (next) onChange(next);
}}
>
{options.map((o) => (
<option key={getLabel(o)}>{getLabel(o)}</option>
))}
</select>
);
}
使用时 T 会被 options 自动推导,onChange 里的 item 立刻拥有完整类型,不需要任何断言。
<Select options={users} value={current} getLabel={(u) => u.name} onChange={setCurrent} />
// u 自动推断为 User,setCurrent 的参数类型也被校验
注意:如果写成箭头函数组件 const Select = <T>(...) => ...,.tsx 里 TS 会把 <T> 当成 JSX 标签解析,必须写成 <T,> 或 <T extends unknown> 才能编译通过。
第五步:给泛型加约束和默认值
泛型不受约束时,组件内部什么都干不了。用 extends 收紧:
interface ListProps<T extends { id: string | number }> {
data: T[];
renderItem: (item: T) => React.ReactNode;
empty?: React.ReactNode;
}
export function List<T extends { id: string | number }>({
data, renderItem, empty = null,
}: ListProps<T>) {
if (data.length === 0) return <>{empty}</>;
return <ul>{data.map((item) => <li key={item.id}>{renderItem(item)}</li>)}</ul>;
}
约束之后,item.id 可以直接用于 key,不用再断言一次。需要默认值时写 <T = string>,调用方不传也能成立。
第六步:泛型组件转发的 ref
React 19 起 ref 可以直接作为普通 prop 传入,类型写成:
interface InputProps<T> {
name: keyof T;
ref?: React.Ref<HTMLInputElement>;
}
React 18 及以前则用 forwardRef,配合泛型时有名的坑是它会丢掉类型参数,通常要写一层断言或改用函数重载包装,这也是升级到 19 之后舒服很多的原因。
第七步:把重复的类型抽成工具类型
不要手写重复的接口。后端返回的实体基础上做派生:
type User = { id: string; name: string; email: string; createdAt: string };
type UserForm = Omit<User, 'id' | 'createdAt'>; // 表单字段
type UserPatch = Partial<Pick<User, 'name' | 'email'>>; // 局部更新
type UserMap = Record<string, User>; // 索引表
字典常量用 as const 而不是 enum,能同时拿到字面量类型和运行时的值:
const ROLES = { admin: '管理员', user: '普通用户' } as const;
type Role = keyof typeof ROLES;
注意:as const 之后不要再对同一对象做 as SomeType,那会把字面量类型抹平回宽类型,前面的推导全白做。
小结
- 先开
strict,再谈其他技巧;没有严格模式的类型标注收益很有限。
- Props 用
interface,联合类型和派生用 type;children 用 React.ReactNode。
- 事件、ref、
useState 的联合类型显式标注,禁止 any,as 只留给确实无法推导的边界。
- 泛型组件用
extends 加约束,让组件内部能安全访问字段;箭头函数写法记得加 <T,>。
- 重复结构用
Omit/Pick/Partial/Record 派生,常量用 as const 换取字面量类型。