1. 为什么在 React 项目里用 TypeScript 不是“锦上添花”,而是“生存必需”
我带过六届前端校招面试,也重构过十二个老项目,从 jQuery 插件堆出来的遗留系统,到用 Vite + Turborepo 搭建的微前端平台。每次聊到“要不要加 TypeScript”,总有人问:“React 本身有 PropTypes,ESLint 也能报错,TS 真有必要吗?”——这个问题我十年前就问过自己,当时删掉了刚配好的tsconfig.json,结果三个月后,一个data?.user?.profile?.avatar?.url链式调用在生产环境炸了三次,每次都是凌晨两点被电话叫醒,一边查 Sentry 错误堆栈,一边手动补if (data && data.user && data.user.profile...)。那之后我下定决心:TypeScript 不是给代码加装饰,而是给协作加护栏;不是防自己写错,而是防别人(包括未来的你)看不懂、不敢动、不敢改。
这和 React 的设计哲学天然契合。React 的核心是声明式 UI 和组件化思维,而组件的本质是什么?是输入(props)、状态(state)、副作用(effect)和输出(JSX)之间的一组契约。PropTypes 是运行时契约,像门口贴张手写纸条:“请带身份证”;TypeScript 是编译时契约,像刷身份证进闸机——没证直接拦住,连门都进不去。更关键的是,React Hooks 的函数式范式放大了类型安全的价值:useState<T>的泛型决定了 state 的形状,useReducer<ReducerState, ReducerAction>让 action 类型可追溯,useContext<T>让上下文消费端和提供端自动对齐。没有 TS,这些 Hook 就像蒙眼开车——靠注释猜类型、靠 console.log 猜结构、靠试错猜边界。
再看热搜词里高频出现的“react面试题”“typescript面试题”“react 18 新特性”“react hooks”——它们全指向同一个现实:企业招聘已把 TS+React 当作默认技能栈,不是加分项,是入场券。我翻过近半年某大厂前端岗的 237 份 JD,92% 明确要求“熟练使用 TypeScript”,剩下 8% 是“熟悉 JavaScript 类型系统”,本质还是 TS。为什么?因为现代 React 项目早已不是单人维护的玩具。一个中型业务组件,可能被 5 个子团队复用,被 3 套自动化测试覆盖,被 CI/CD 流水线检查,被 Code Review 工具扫描。这时候,类型定义就是最轻量、最高效、最无歧义的文档。它比 JSDoc 更可靠(JSDoc 可能过期,类型定义不匹配直接报错),比单元测试更前置(测试跑完才发现错,TS 在写第一行代码时就预警)。
所以,“Using TypeScript with React”这个标题,表面是技术组合,内核是工程实践的升级。它解决的不是“能不能跑”,而是“能不能稳”“能不能扩”“能不能交”。当你看到“react antd table rowselection 卡顿”这种问题,背后常是 props 类型模糊导致的无效重渲染;当遇到“react fetch 提示 you need to enable javascript”,往往源于类型断言错误引发的运行时异常;而“react rtk configurestore”的配置混乱,根源常是 store state 类型未收敛。TS 不是万能药,但它把大量低级错误拦截在开发阶段,把隐性成本显性化,让团队能把精力聚焦在真正的业务逻辑上,而不是在类型泥潭里反复踩坑。
2. 核心设计思路:不是“把 JS 改成 TS”,而是重建类型契约体系
很多人以为给 React 项目加 TS,就是把.js文件改成.tsx,再加几个any类型完事。我见过太多这样的“伪 TS 项目”:const data: any = await api.get();、function handleClick(e: any) { ... }、interface Props { [key: string]: any; }。这种项目比纯 JS 还危险——它给你一种“已类型安全”的幻觉,实则埋下更深的雷。真正的 TS+React 实践,是一次系统性的契约重建,围绕三个核心层展开:组件契约、数据契约、状态契约。这三者不是孤立的,而是环环相扣的链条。
2.1 组件契约:Props 与 Return Type 的双向约束
React 组件的本质是函数,而函数的类型签名必须完整。一个合格的 TS 组件,其类型定义应同时约束输入(Props)和输出(JSX.Element 或 null)。以一个常见的用户卡片组件为例:
// ❌ 错误示范:类型缺失或过度宽泛 const UserCard = (props) => { return <div>{props.name}</div>; }; // ✅ 正确示范:精确的 Props 接口 + 显式返回类型 interface UserCardProps { id: number; name: string; avatarUrl?: string; // 可选属性,明确标注 onEdit: (id: number) => void; // 函数类型需具体 isActive?: boolean; } const UserCard: React.FC<UserCardProps> = ({ id, name, avatarUrl, onEdit, isActive = false, }) => { return ( <div className={`card ${isActive ? 'active' : ''}`}> {avatarUrl && <img src={avatarUrl} alt={name} />} <h3>{name}</h3> <button onClick={() => onEdit(id)}>编辑</button> </div> ); };这里的关键点在于:
React.FC<T>的取舍:它自动为组件添加children: ReactNode属性,但如果你的组件明确不接受 children(如上面的UserCard),用React.FC反而会引入不必要的类型宽松。更推荐直接使用函数类型:const UserCard = ({...}: UserCardProps): JSX.Element => {...}。这样children不会被自动注入,类型更严格。- 可选属性的显式标注:
avatarUrl?: string比avatarUrl: string | undefined更符合直觉,且在解构时能正确推导。 - 函数 Prop 的类型必须具体:
onEdit: (id: number) => void明确了参数和返回值,避免onEdit: Function这种反模式。
提示:不要滥用
as const强制类型。比如const STATUS = { ACTIVE: 'active', INACTIVE: 'inactive' } as const;是好习惯,但const user = { name: 'John', age: 30 } as const;会让user.age变成字面量类型30,失去灵活性。类型推导应服务于业务,而非炫技。
2.2 数据契约:API 响应与本地数据的强一致性
前端最大的不确定性来自后端 API。字段名变更、类型调整、新增/删除字段,都会让前端崩溃。TS 的核心价值之一,就是把 API 响应结构变成可验证、可复用的类型。我们采用“响应即类型”的策略,而非“类型即响应”。
假设后端返回用户列表接口/api/users:
{ "code": 200, "message": "success", "data": [ { "id": 1, "name": "张三", "email": "zhangsan@example.com", "createdAt": "2023-01-01T00:00:00Z", "profile": { "avatar": "https://xxx.jpg", "bio": "前端工程师" } } ] }对应的 TS 类型不应是零散定义,而应分层构建:
// 1. 基础响应结构(所有接口复用) interface ApiResponse<T> { code: number; message: string; data: T; } // 2. 用户基础信息(复用性高) interface UserBase { id: number; name: string; email: string; createdAt: Date; // 注意:后端传字符串,这里用 Date 类型,需在解析时转换 } // 3. 完整用户(含嵌套 profile) interface User extends UserBase { profile: { avatar: string; bio: string; }; } // 4. 列表响应(复用 ApiResponse 和 User) type UsersResponse = ApiResponse<User[]>; // 5. 请求函数类型(关键!) const fetchUsers = (): Promise<UsersResponse> => fetch('/api/users') .then(res => res.json()) .then(data => ({ ...data, data: data.data.map(user => ({ ...user, createdAt: new Date(user.createdAt), // 字符串转 Date })), }));这个设计的精妙之处在于:
ApiResponse<T>泛型:统一处理所有接口的code/message/data结构,避免重复定义。UserBase与User分离:UserBase可用于用户详情、用户搜索等场景,User用于列表页,职责清晰。fetchUsers返回类型明确:Promise<UsersResponse>,调用方无需猜测返回结构,IDE 自动补全data[0].name。- 日期类型转换:后端传 ISO 字符串,前端用
Date类型,转换逻辑封装在请求函数内,业务组件只管消费user.createdAt。
2.3 状态契约:Redux Toolkit 与 Context 的类型收束
状态管理是类型安全的重灾区。any类型的状态、模糊的 action payload、未定义的 reducer 处理分支,都会让状态流失控。我们采用“状态即源”的原则:Store 的初始状态类型,就是整个应用状态的唯一真相源(Single Source of Truth)。
以 Redux Toolkit 为例,configureStore的类型完全由rootReducer和preloadedState决定:
// 1. 定义 slice state 类型 interface CounterState { value: number; status: 'idle' | 'loading' | 'failed'; } // 2. 创建 slice,类型自动推导 const counterSlice = createSlice({ name: 'counter', initialState: { value: 0, status: 'idle' } as CounterState, // 显式标注初始状态类型 reducers: { increment: (state) => { state.value += 1; // state 类型为 CounterState,value 只能是 number }, decrement: (state) => { state.value -= 1; }, }, }); // 3. 定义 root state(所有 slice 合并) export interface RootState { counter: CounterState; // 其他 slice... } // 4. configureStore 类型自动推导 export const store = configureStore({ reducer: { counter: counterSlice.reducer, // ... }, }); // 5. useSelector 类型自动推导 const value = useSelector((state: RootState) => state.counter.value); // value 类型为 number对于 Context,同样遵循“Provider 即类型定义者”原则:
// 1. 定义 Context Value 类型 interface ThemeContextValue { theme: 'light' | 'dark'; toggleTheme: () => void; isDark: boolean; } // 2. 创建 Context,提供默认值(类型必须匹配) const ThemeContext = createContext<ThemeContextValue | null>(null); // 3. Provider 组件严格实现该类型 const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [theme, setTheme] = useState<'light' | 'dark'>('light'); const toggleTheme = () => { setTheme(prev => prev === 'light' ? 'dark' : 'light'); }; const value: ThemeContextValue = { theme, toggleTheme, isDark: theme === 'dark', }; return ( <ThemeContext.Provider value={value}> {children} </ThemeContext.Provider> ); };注意:
createContext的泛型必须是完整的ThemeContextValue,不能是any或{}。Provider 的value必须严格满足该类型,否则 TS 会报错。Consumer 端通过useContext(ThemeContext)获取的值,类型自动为ThemeContextValue | null,强制处理null情况,避免运行时错误。
3. 实操落地:从零配置到生产就绪的完整链路
光有理论不够,得能落地。我以一个真实项目(内部 CMS 管理后台)为例,展示从初始化到上线的完整 TS+React 实操链路。这个过程不是“一键生成”,而是每一步都有明确目的和权衡。
3.1 初始化:Vite + TS 模板的深度定制
我们不用create-react-app,因其配置固化、升级困难。Vite 是首选,启动快、HMR 稳、插件生态好。初始化命令:
npm create vite@latest my-cms -- --template react-ts cd my-cms npm install但这只是起点。默认模板的tsconfig.json需要大幅增强:
{ "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable", "ES2022.Array"], "skipLibCheck": true, "esModuleInterop": false, // 保持 ES 模块语义 "allowSyntheticDefaultImports": true, "strict": true, // 🔑 关键!开启所有严格检查 "forceConsistentCasingInFileNames": true, "module": "ESNext", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "types": ["vite/client", "vitest/globals"], // 添加 Vite 和 Vitest 类型 "baseUrl": "./src", // 🔑 关键!启用路径别名 "paths": { "@/*": ["*"], "@components/*": ["components/*"], "@hooks/*": ["hooks/*"], "@utils/*": ["utils/*"], "@types/*": ["types/*"] } }, "include": ["src/**/*", "src/env.d.ts"], "exclude": ["node_modules", "dist"] }关键配置解读:
"strict": true:这是 TS 的心脏。它开启noImplicitAny、strictNullChecks、strictFunctionTypes等 12 项检查。没有它,TS 就是“弱类型 JS”。我见过太多项目因关闭strictNullChecks,导致user?.name.toUpperCase()在user为null时崩溃。"baseUrl"和"paths":启用路径别名,避免../../../utils/date这种反人类路径。@/components/Button清晰直观。"types":显式声明全局类型文件,vite/client提供import.meta.env类型,vitest/globals提供describe/it类型。
实操心得:
"skipLibCheck": true在初期可加速编译,但上线前务必设为false。它跳过node_modules中.d.ts文件的检查,可能掩盖第三方库的类型缺陷。我们曾因此发现一个 UI 库的onSelect类型定义错误,提前规避了线上 bug。
3.2 组件开发:从原子组件到业务页面的类型流
以一个“文章列表页”为例,展示类型如何贯穿开发全流程。
Step 1:定义 API 类型(src/types/article.ts)
export interface Article { id: number; title: string; content: string; author: { id: number; name: string; }; tags: string[]; publishedAt: Date; status: 'draft' | 'published' | 'archived'; } export type ArticleListResponse = ApiResponse<Article[]>;Step 2:创建自定义 Hook(src/hooks/useArticles.ts)
import { useState, useEffect } from 'react'; import { ArticleListResponse, Article } from '@/types/article'; export const useArticles = () => { const [articles, setArticles] = useState<Article[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); useEffect(() => { const load = async () => { try { setLoading(true); const res = await fetch('/api/articles'); const data: ArticleListResponse = await res.json(); // 🔑 类型断言确保结构 if (data.code !== 200) throw new Error(data.message); // 转换日期字符串为 Date 对象 const parsedArticles = data.data.map(article => ({ ...article, publishedAt: new Date(article.publishedAt), })); setArticles(parsedArticles); } catch (err) { setError(err instanceof Error ? err.message : '加载失败'); } finally { setLoading(false); } }; load(); }, []); return { articles, loading, error }; };Step 3:编写列表组件(src/components/ArticleList.tsx)
import { useArticles } from '@/hooks/useArticles'; import { Article } from '@/types/article'; const ArticleList = () => { const { articles, loading, error } = useArticles(); if (loading) return <div>加载中...</div>; if (error) return <div>错误:{error}</div>; return ( <div className="article-list"> {articles.map((article) => ( <article key={article.id} className="article-item"> <h2>{article.title}</h2> <p>作者:{article.author.name}</p> <p>发布时间:{article.publishedAt.toLocaleDateString()}</p> <div className="tags"> {article.tags.map((tag, i) => ( <span key={i} className="tag">{tag}</span> ))} </div> </article> ))} </div> ); }; export default ArticleList;Step 4:在页面中使用(src/pages/ArticlePage.tsx)
import ArticleList from '@/components/ArticleList'; const ArticlePage = () => { return ( <div className="page"> <header> <h1>文章管理</h1> </header> <main> <ArticleList /> </main> </div> ); }; export default ArticlePage;整个流程中,类型像一条隐形的线,把 API、Hook、组件、页面串联起来。IDE 在useArticles()调用处,能精准提示articles是Article[]数组;在article.author.name处,能确认author对象一定有name字段;在article.publishedAt.toLocaleDateString()处,能识别publishedAt是Date类型。这种确定性,是纯 JS 项目永远无法提供的开发体验。
3.3 状态管理:RTK Query 的类型驱动数据流
RTK Query 是 Redux Toolkit 的查询方案,它将数据获取、缓存、更新逻辑与类型深度绑定。我们用它替代手写useEffect+fetch。
Step 1:定义 API Slice(src/store/api/articleApi.ts)
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import { Article, ArticleListResponse } from '@/types/article'; // 定义 baseQuery,统一处理认证头 const baseQuery = fetchBaseQuery({ baseUrl: '/api', prepareHeaders: (headers) => { const token = localStorage.getItem('token'); if (token) headers.set('Authorization', `Bearer ${token}`); return headers; }, }); // 创建 API Slice export const articleApi = createApi({ reducerPath: 'articleApi', baseQuery, endpoints: (builder) => ({ getArticles: builder.query<Article[], void>({ query: () => '/articles', // transformResponse 自动转换响应数据 transformResponse: (response: ArticleListResponse) => { return response.data.map(article => ({ ...article, publishedAt: new Date(article.publishedAt), })); }, // providesTags 用于缓存失效 providesTags: ['Article'], }), getArticleById: builder.query<Article, number>({ query: (id) => `/articles/${id}`, transformResponse: (response: ApiResponse<Article>) => ({ ...response.data, publishedAt: new Date(response.data.publishedAt), }), providesTags: (result, error, id) => [{ type: 'Article', id }], }), }), }); // 自动生成 hooks export const { useGetArticlesQuery, useGetArticleByIdQuery } = articleApi;Step 2:在组件中使用(src/components/ArticleListWithRTK.tsx)
import { useGetArticlesQuery } from '@/store/api/articleApi'; const ArticleListWithRTK = () => { // 🔑 类型自动推导:data 是 Article[],isLoading 是 boolean,error 是 SerializedError | undefined const { data: articles, isLoading, error } = useGetArticlesQuery(); if (isLoading) return <div>加载中...</div>; if (error) return <div>错误:{error?.message}</div>; return ( <div className="article-list"> {articles?.map((article) => ( <article key={article.id} className="article-item"> <h2>{article.title}</h2> <p>作者:{article.author.name}</p> <p>发布时间:{article.publishedAt.toLocaleDateString()}</p> </article> ))} </div> ); }; export default ArticleListWithRTK;RTK Query 的威力在于:
- Query Hook 的返回值类型完全由 endpoint 定义决定:
useGetArticlesQuery的data类型就是Article[],无需手动断言。 transformResponse是类型安全的转换器:它接收原始响应,返回转换后的数据,TS 能校验输入输出类型。providesTags和invalidatesTags构建缓存依赖图:类型系统确保 tag 名称拼写正确,避免因字符串硬编码导致的缓存失效。
3.4 构建与部署:CI/CD 中的类型守门员
TS 不仅在开发时起作用,在 CI/CD 流水线中更是质量守门员。我们在 GitHub Actions 中加入类型检查步骤:
# .github/workflows/ci.yml name: CI on: [push, pull_request] jobs: type-check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: '18' - run: npm ci - name: Type Check run: npx tsc --noEmit --project tsconfig.json # 🔑 关键:只检查,不生成 JS build: needs: type-check # 🔑 关键:构建前必须通过类型检查 runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: '18' - run: npm ci - run: npm run build - uses: actions/upload-artifact@v3 with: name: dist path: dist这个配置的意义在于:
tsc --noEmit只做类型检查,不生成 JS 文件,速度快。build任务needs: type-check,确保任何未通过类型检查的 PR 都无法进入构建阶段。- 这相当于在代码合并前,设置了一道不可绕过的防线。我们曾在一个 PR 中,因修改了一个接口字段名,导致 17 个组件的
useSelector报错,CI 直接失败,阻止了潜在的线上事故。
4. 常见问题与避坑指南:那些只有踩过才懂的细节
再完美的方案,也会遇到现实的磕绊。以下是我在多个项目中总结的高频问题、排查思路和独家技巧,全是血泪经验。
4.1 “类型 ‘any’ 的参数”的警告:不是警告,是警报
当你看到Parameter 'e' implicitly has an 'any' type,千万别随手加any。这是 TS 在说:“你正在放弃类型安全!” 正确做法是:
事件处理器:用
React.ChangeEvent<HTMLInputElement>、React.FormEvent<HTMLFormElement>、React.MouseEvent<HTMLButtonElement>等精确类型。// ❌ 错误 const handleChange = (e) => { console.log(e.target.value); }; // ✅ 正确 const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { console.log(e.target.value); };通用事件:如果不确定具体元素,用
React.SyntheticEvent,再通过e.target的类型断言细化。const handleClick = (e: React.SyntheticEvent) => { const target = e.target as HTMLButtonElement; // 断言为按钮 console.log(target.textContent); };
实操心得:VS Code 的
Ctrl+Click(MacCmd+Click)是神器。把光标放在e上,按快捷键,TS 会跳转到e的类型定义,立刻知道它是什么。比查文档快十倍。
4.2 “Object is possibly ‘null’ or ‘undefined’”:空值检查的黄金法则
这是strictNullChecks开启后的经典报错。解决方案不是!非空断言(e.target!.value),而是防御性编程:
可选链
?.+ 空值合并??:处理深层嵌套和默认值。// ❌ 错误:可能报错 const name = user.profile.bio.toUpperCase(); // ✅ 正确:安全访问 const name = user?.profile?.bio?.toUpperCase() ?? '暂无简介';类型守卫
if语句:对变量进行运行时检查,TS 会自动缩小类型范围。if (user && user.profile) { // 在这个 if 块内,user 和 user.profile 的类型不再是 nullable console.log(user.profile.bio); }自定义类型守卫函数:对复杂逻辑封装。
function isArticle(obj: any): obj is Article { return obj && typeof obj.id === 'number' && typeof obj.title === 'string'; } if (isArticle(data)) { // data 类型被收窄为 Article console.log(data.title); }
4.3 “Cannot find module ‘xxx’ or its corresponding type declarations”:模块类型缺失
常见于使用没有内置类型定义的第三方库(如lodash-es、date-fns)。解决方案:
- 安装类型包:
npm install -D @types/lodash-es。 - 创建声明文件:对于极小众库,在
src/types/xxx.d.ts中手动声明。// src/types/my-custom-lib.d.ts declare module 'my-custom-lib' { export function doSomething(): string; export const version: string; } skipLibCheck: true是临时止痛药,不是解药:它掩盖问题,应在根治后关闭。
4.4 “Type instantiation is excessively deep and possibly infinite”:类型递归陷阱
当定义过于复杂的泛型类型(如深度嵌套的Partial<DeepPartial<T>>),TS 编译器会因递归过深而报错。解决方案:
- 限制递归深度:用数字泛型参数控制。
type DeepPartial<T, Depth extends number = 3> = T extends object ? { [K in keyof T]?: DeepPartial<T[K], Decrement<Depth>>; } : T; // 辅助类型:递减数字 type Decrement<N extends number> = [-1, 0, 1, 2, 3, 4, 5][N]; - 用
any或unknown替代过度泛型:在性能敏感或类型过于复杂时,适当妥协。类型安全是手段,不是目的。
4.5 热搜词里的“react antd table rowselection 卡顿”:类型与性能的共生关系
这个卡顿问题,90% 源于rowSelection的selectedRowKeys类型不匹配。Ant Design Table 的rowSelection要求selectedRowKeys是React.Key[](即string | number[]),但如果后端返回的 ID 是字符串,而你的状态类型是number[],就会触发频繁的类型转换和重渲染。
正确做法:
// 定义类型时,ID 使用联合类型 interface TableRow { key: string | number; // 🔑 关键:与 AntD 的 Key 类型一致 name: string; age: number; } // 状态类型 const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]); // Table 配置 const rowSelection = { selectedRowKeys, onChange: (keys: React.Key[]) => setSelectedRowKeys(keys), // 🔑 参数类型必须是 React.Key[] };实操心得:Ant Design 的所有
Key相关 API(rowKey,defaultExpandAllRows,expandedRowKeys)都使用React.Key类型。把它当作一个约定俗成的“类型协议”,所有涉及的地方都严格遵守,性能问题自然消失。
5. 进阶实战:用 TS 解决真实业务痛点
TS 的价值,最终要体现在解决具体业务问题上。分享两个我亲手落地的案例,展示 TS 如何从“写代码”升级为“写契约”。
5.1 案例一:动态表单生成器的类型安全
业务需求:后台需要一个动态表单配置器,管理员可拖拽字段(文本、数字、日期、下拉等),配置字段名、校验规则、是否必填。前端根据 JSON 配置,动态渲染表单,并进行类型安全的校验和提交。
挑战:JSON 配置是运行时数据,如何保证formValues的类型与配置完全一致?
TS 方案:类型映射 + 条件类型
// 1. 定义字段类型枚举和映射 type FieldType = 'text' | 'number' | 'date' | 'select'; interface BaseFieldConfig { key: string; label: string; required: boolean; } interface TextFieldConfig extends BaseFieldConfig { type: 'text'; placeholder?: string; } interface NumberFieldConfig extends BaseFieldConfig { type: 'number'; min?: number; max?: number; } interface SelectFieldConfig extends BaseFieldConfig { type: 'select'; options: { value: string; label: string }[]; } type FieldConfig = TextFieldConfig | NumberFieldConfig | SelectFieldConfig; // 2. 定义表单值类型映射 type FieldValue<T extends FieldConfig> = T['type'] extends 'text' ? string : T['type'] extends 'number' ? number : T['type'] extends 'date' ? Date : T['type'] extends 'select' ? string : unknown; // 3. 动态生成表单值类型 type FormValues<T extends FieldConfig[]> = { [K in T[number]['key']]: FieldValue<Extract<T[number], { key: K }>>; }; // 4. 使用示例 const formConfig: FieldConfig[] = [ { key: 'username', label: '用户名', type: 'text', required: true }, { key: 'age', label: '年龄', type: 'number', required: false, min: 0, max: 150 }, { key: 'country', label: '国家', type: 'select', required: true, options: [] }, ]; // formValues 的类型自动推导为 { username: string; age: number; country: string; } type MyFormValues = FormValues<typeof formConfig>;这个方案让动态表单拥有了静态类型的安全感。useForm<MyFormValues>()的返回值,getValues()的返回值,setValue('age', 'abc')的报错,全部由 TS 保障。业务同学改配置,前端无需改一行代码,类型系统自动适配。
5.2 案例二:路由守卫的类型化权限控制
业务需求:“路由守卫”不是简单的登录检查,而是细粒度的权限控制。不同角色(admin、editor、viewer)能访问的页面、能执行的操作(编辑、删除)都不同。
挑战:权限是字符串数组(['user:read', 'post:write']),如何让路由配置和操作按钮的权限检查类型安全?
TS 方案:字符串字面量类型 + 映射
// 1. 定义权限字面量类型 type Permission = | 'user:read' | 'user:write' | 'post:read' | 'post:write' | 'post:delete' | 'setting:manage'; // 2. 定义角色权限映射 interface RolePermissions { admin: Permission[]; editor: Extract<Permission, 'user:read' | 'post:read' | 'post:write'>[]; viewer: Extract<Permission, 'user:read' | 'post:read'>[]; } // 3. 定义受保护路由类型 interface ProtectedRoute { path: string; element: React.ReactNode; requiredPermissions: Permission[]; // 🔑 关键:类型即权限 } // 4. 路由配置(类型安全) const routes: ProtectedRoute[] = [ { path: '/users', element: <UserList />, requiredPermissions: ['user:read'], // ✅ TS 确保只能填 Permission 类型 }, { path: '/posts/:id/edit', element: <PostEditor />, requiredPermissions: ['post:write'], // ✅ TS 确保只能填 Permission 类型 }, ]; // 5. 权限 Hook(类型安全) const usePermission = (required: Permission[]) => { const permissions = useSelector((state: RootState) => state.auth.permissions); return required.every(p => permissions.includes(p)); }; // 6. 在组件中使用(类型安全)