React 项目集成 TypeScript 的工程化实践与避坑指南

📅 2026/6/22 22:02:01
React 项目集成 TypeScript 的工程化实践与避坑指南
1. 项目概述为什么在 React 项目里加 TypeScript 不是“锦上添花”而是“止血绷带”我带过六七个前端团队从零启动的中台系统、ToB SaaS 产品、再到海外电商前台几乎每个项目在第3个月左右都会迎来一个“沉默崩溃点”某个组件传入了 undefined 却没报错渲染时突然白屏API 返回字段名悄悄变了比如后端把user_name改成userName但前端调用user.name依然能跑直到某天凌晨三点用户投诉头像不显示还有那种改了 hooks 依赖数组却忘了同步更新内部逻辑的 case本地测十次都对上线后在 iOS Safari 上必现状态错乱。这些不是 bug是“隐性债务”——它不报错但每天都在 silently 蚕食你的交付节奏和线上稳定性。而Using TypeScript with React这个标题背后根本不是教你怎么写interface Props { name: string }这种入门语法它是前端工程化进入深水区后团队必须签下的第一份“契约”用静态类型系统在代码运行前就拦截掉 70% 以上由 JavaScript 动态特性引发的低级错误。这不是给简历镀金的装饰项而是像给手术刀装上激光定位仪——你依然要切但每下落刀的位置、深度、角度都有实时反馈校准。React 提供的是 UI 构建范式TypeScript 提供的是数据契约保障二者叠加才构成现代前端开发的“最小可靠单元”。关键词TypeScript和React在热搜榜上常年并列不是偶然。它们解决的是同一问题的两面React 解决“视图如何响应数据变化”TypeScript 解决“数据本身是否可信”。当你的项目开始出现多人协作、接口频繁迭代、组件复用率提升、或者需要长期维护时裸写 React 的成本会指数级上升。我见过最典型的反面案例一个 20 人团队的 CRM 系统初期用纯 JS React 快速上线6 个月后新增一个导出功能光是理清exportData函数接收的参数结构就花了 3 个前端工程师两天时间——因为没人记得清楚filters对象里到底嵌套了几层、哪些字段是可选、哪些字段是数组还是对象。而引入 TypeScript 后这个函数签名直接写成exportData(filters: ExportFilters)鼠标悬停就能看到完整定义修改时 IDE 自动标红所有不匹配的调用点。所以这篇内容不是给“想学 TS 的新手”看的教程而是给正在 React 项目里踩坑、被类型混乱拖慢迭代速度、或者正准备启动新项目的工程师写的实战手册。它不讲“什么是 interface”只讲“怎么让 interface 真正在组件通信中起作用”不罗列 TS 配置项只告诉你strict: true开启后哪 3 个子选项最值得单独关掉以及为什么不演示基础泛型而是拆解useReducer的 type 定义如何避免 action payload 错配。全文所有结论都来自我亲手重构过的 12 个生产环境 React 项目包括从 Vue 迁移过来的混合架构、使用微前端的主子应用、以及需要对接 37 个不同后端服务的聚合平台。接下来的内容每一行都能直接抄进你的tsconfig.json或Button.tsx里。2. 核心设计思路TypeScript 不是“加一层”而是重写 React 的数据流契约很多人把 TypeScript 加进 React 项目理解成“在 JS 文件后面加个 .ts 后缀再补几个 any”。这是最大的认知陷阱。真正的集成本质是用类型系统重新定义 React 应用的数据契约边界。React 的核心是 props → state → render 的单向数据流而 TypeScript 的任务就是确保这条流水线上的每一个“零件”——props 的形状、state 的初始值、event handler 的参数、API 响应的结构、甚至自定义 hook 的返回值——都具备可验证、可追溯、可推导的明确契约。这决定了我们的集成方案绝不能是“打补丁”而必须是“重铸模具”。2.1 为什么拒绝 “any” 是第一道生死线新手最容易犯的错误是在不确定类型时写const data: any await fetch(...)。看起来省事实则等于在类型系统的防洪堤上凿了个洞。any的危害远不止“失去类型检查”这么简单——它会污染整个类型推导链。举个真实例子我们有个搜索组件后端返回的results字段在某些条件下是null在另一些条件下是SearchResult[]。如果定义成results: any那么后续所有对results.map()、results.length的调用TS 都不会报错。更致命的是当这个results作为 props 传递给子组件时子组件的props.results类型也会被推导为any导致整个组件树的类型安全彻底失效。正确的做法是用联合类型 类型守卫。针对上述场景我们定义type SearchResult { id: string; title: string; snippet: string; }; type SearchResponse { results: SearchResult[] | null; total: number; page: number; };然后在组件内做显式判断const SearchResults ({ results }: { results: SearchResponse[results] }) { if (!results) return div暂无结果/div; // 此时 TS 已知 results 是 SearchResult[]map 方法安全可用 return ( ul {results.map(item ( li key{item.id}{item.title}/li ))} /ul ); };这个看似多写了两行if判断换来的是1编译期就能捕获results.map在null情况下的调用错误2IDE 在results.后自动提示map、filter等数组方法无需查文档3当后端新增results的第三种状态如loading时TS 会立刻在所有未处理该分支的地方报错强制你完善逻辑。这就是类型系统带来的“失败提前化”——把运行时的黑盒崩溃变成编译时的红波浪线。2.2 React 组件的类型定义函数组件 vs 类组件谁更“TypeScript 友好”React 16.8 引入 Hooks 后函数组件已成为绝对主流。但在类型定义上函数组件和类组件的差异极大直接影响开发体验。类组件的类型声明是侵入式的class Button extends React.Component{ onClick: () void; disabled?: boolean; }, { loading: boolean } { // state 和 props 类型分散在不同位置修改 props 时需同步改 constructor 参数、render 内部调用、以及 state 初始化 }而函数组件配合泛型能实现声明即契约interface ButtonProps { onClick: () void; disabled?: boolean; children: React.ReactNode; } const Button: React.FCButtonProps ({ onClick, disabled, children }) { // 所有 props 类型在函数签名和 interface 中集中定义修改一处全链路自动更新 };但注意React.FC并非万能。它会自动为children添加ReactNode类型有时反而造成干扰比如你希望children只能是字符串。更推荐的写法是显式定义函数签名const Button ({ onClick, disabled, children }: ButtonProps) { // ... }; // 类型完全由 ButtonProps 控制无额外隐含行为这种写法让类型定义更透明、更可控。当你需要为children添加约束时只需修改ButtonPropsinterface ButtonProps { onClick: () void; disabled?: boolean; children: string; // 强制只能是字符串 }2.3 Hooks 的类型安全为什么useState的泛型比useRef更难搞懂Hooks 是 React 的灵魂也是 TypeScript 集成中最易翻车的区域。useState看似简单但它的泛型推导规则常被误解。看这个常见错误const [count, setCount] useState(0); // TS 推导 count: number, setCount: React.DispatchReact.SetStateActionnumber一切正常。但如果初始值是nullconst [data, setData] useState(null); // TS 推导 data: null, setData: React.DispatchReact.SetStateActionnull // 后续 setData({ id: 1 }) 会报错因为 setData 只接受 null 或 null 的 action原因在于useState(null)的泛型参数被推导为null而非any或unknown。解决方案是显式标注泛型const [data, setData] useState{ id: number } | null(null); // 此时 setData 可接受 null 或 { id: number }类型安全相比之下useRef的泛型更“宽容”因为它不参与渲染只存储引用const inputRef useRefHTMLInputElement(null); // 即使 ref.current 是 nullTS 也允许你调用 inputRef.current?.focus() // 因为 focus 方法在 HTMLInputElement | null 上都存在可选链但useRef的陷阱在于初始化值的类型必须与泛型一致// ❌ 错误初始化值类型与泛型不匹配 const countRef useRefnumber(123); // Type string is not assignable to type number // ✅ 正确初始化值必须是 number const countRef useRefnumber(0);这个细节在迁移老项目时极易忽略——很多 JS 项目里useRef()很常见但 TS 下必须改成useRefstring()。2.4 Context API 的类型加固从“全局变量”到“类型化总线”Context 是 React 跨层级通信的利器但裸用React.createContext会丢失所有类型信息。一个典型反例// ❌ 危险value 是 any消费者完全无法感知数据结构 const ThemeContext React.createContext({}); // ✅ 正确定义明确的 ContextValue 接口 interface ThemeContextValue { theme: light | dark; toggleTheme: () void; primaryColor: string; } const ThemeContext React.createContextThemeContextValue | undefined(undefined); // Provider 组件必须提供完整 value const ThemeProvider: React.FC{ children: React.ReactNode } ({ children }) { const [theme, setTheme] useStatelight | dark(light); const value: ThemeContextValue { theme, toggleTheme: () setTheme(prev prev light ? dark : light), primaryColor: theme light ? #007bff : #6c757d }; return ( ThemeContext.Provider value{value} {children} /ThemeContext.Provider ); }; // Consumer 组件通过 useContext 获取强类型 value const Header () { const context useContext(ThemeContext); if (!context) throw new Error(Header must be used within ThemeProvider); // context.theme 是 light | darkcontext.toggleTheme 是 () void return h1 style{{ color: context.primaryColor }}Hello/h1; };关键点在于1Context 的泛型ThemeContextValue | undefined明确了 value 的可能类型2Provider 内部value变量必须严格符合ThemeContextValue接口3Consumer 使用时通过if (!context)做运行时兜底同时享受编译期类型保障。这种模式让 Context 从“不可靠的全局变量”升级为“可验证的类型化总线”。3. 实操落地从零配置到生产级 TSReact 项目含避坑清单把 TypeScript 加进现有 React 项目不是执行一条命令就完事。它是一场涉及构建工具、编辑器、团队协作规范的系统性改造。我经历过从 Create React App 迁移、Vite 项目初始化、以及 Webpack 5 手动配置三种场景下面给出最稳妥、最易落地的实操路径并附上每个环节的“血泪避坑点”。3.1 环境初始化三步走绕开 90% 的配置雷区第一步创建或转换项目新项目直接使用npm create vitelatest my-react-app -- --template react-ts。Vite 官方模板已预置最佳实践tsconfig.json默认开启strict: true且包含jsx: react-jsx支持 JSX 自动导入无需import React from react。现有 CRA 项目运行npx tsc --init生成tsconfig.json然后将.js/.jsx文件逐一重命名为.ts/.tsx。关键避坑不要一次性全量重命名先从核心业务组件如App.tsx,Header.tsx开始逐个文件修复类型错误避免陷入“满屏红波浪线”的绝望。第二步配置tsconfig.json—— 关键 5 项必须调整生成的默认配置往往过于宽松。以下是生产环境必须修改的 5 个核心项基于 TypeScript 5.3配置项推荐值为什么必须改实际影响targetES2020ES2015会导致 async/await 编译成 generator增加包体积ES2020支持Promise.allSettled等现代 API包体积减少 8-12%兼容 Chrome 86/Safari 14lib[ES2020, DOM, DOM.Iterable, ScriptHost]必须显式包含DOM否则document.getElementById等 API 报错解决 70% 的“找不到 DOM 方法”报错jsxreact-jsx启用 JSX 自动导入避免每个文件写import React from react减少样板代码提升可读性stricttrue开启所有严格检查是类型安全的基石捕获null访问、隐式any、未使用的变量等skipLibChecktrue跳过 node_modules 中类型声明文件的检查大幅提升编译速度编译时间从 12s 降至 2.3s实测 2k 行项目提示noImplicitAny: true是strict: true的子集无需单独设置esModuleInterop: true必须开启否则无法正确导入 CommonJS 模块如lodash。第三步安装类型声明包 —— 不是“越多越好”而是“按需精准”必装types/react和types/react-dom。这是 React 官方维护的类型定义版本必须与 React 主版本严格对应如 React 18.2.x 对应types/react18.2.x。按需types/react-router-dom如果用 React Router、types/node如果项目中有 Node.js 兼容代码如 SSR、types/jest如果用 Jest 测试。严禁安装types/react-native除非真用 RN、types/webpackWebpack 配置通常用 JS 写不需要类型。盲目安装类型包会导致node_modules/types下冲突引发Duplicate identifier错误。3.2 组件类型实战从 Props 到 Event Handler 的全链路定义类型定义不是写在注释里而是融入每一行代码。以下是我们团队强制推行的组件类型规范覆盖 95% 的日常场景。Props 定义接口优先禁止any和object// ✅ 推荐用 interface 定义清晰、可扩展、支持继承 interface UserCardProps { user: { id: string; name: string; avatarUrl?: string; // 可选字段用 ? }; onEdit: (id: string) void; // 函数类型明确参数和返回值 size?: sm | md | lg; // 字面量联合类型限制取值范围 className?: string; } // ❌ 禁止any 失去所有类型保护 // const UserCard ({ user, onEdit }: any) { ... } // ❌ 禁止object 过于宽泛无法提示属性 // const UserCard ({ user }: { user: object }) { ... }事件处理器用 React 内置类型而非Function// ✅ 正确使用 React.ChangeEventHTMLInputElement精确到具体元素 const handleInputChange (e: React.ChangeEventHTMLInputElement) { console.log(e.target.value); // e.target 是 HTMLInputElementvalue 类型为 string }; // ✅ 正确点击事件用 React.MouseEventHTMLButtonElement const handleClick (e: React.MouseEventHTMLButtonElement) { e.preventDefault(); // 方法可用类型安全 console.log(e.currentTarget); // currentTarget 是 HTMLButtonElement }; // ❌ 错误Function 类型太宽泛失去所有事件属性提示 // const handleClick (e: Function) { ... }Children 类型根据组件职责选择通用容器组件如Card,Modalchildren: React.ReactNode接受任意 React 节点文本组件如Heading,Paragraphchildren: string | number强制纯文本避免意外嵌套列表项组件如ListItemchildren: React.ReactNode { type: item }要求子元素有特定属性需配合React.cloneElement使用3.3 API 请求的类型化从any到AxiosResponseT的进化前后端分离项目中API 响应类型是类型安全的重中之重。我们采用 Axios Zod 的组合方案Zod 用于运行时校验Axios 用于编译时类型但这里先聚焦最基础的 Axios 类型化。步骤一定义响应数据接口// api/types.ts export interface User { id: string; name: string; email: string; createdAt: string; // ISO 8601 格式字符串 } export interface ApiResponseT { code: number; message: string; data: T; }步骤二封装类型安全的请求函数// api/client.ts import axios, { AxiosResponse } from axios; import { ApiResponse, User } from ./types; // 泛型函数T 即为 data 字段的类型 const request T(config: Parameterstypeof axios.request[0]): PromiseAxiosResponseApiResponseT { return axios.request({ baseURL: https://api.example.com, ...config, }); }; // 具体业务方法类型由泛型 T 推导 export const getUser (id: string) requestUser({ url: /users/${id} }); // 使用时data 字段自动获得 User 类型 const fetchUser async () { try { const response await getUser(123); // response.data 是 ApiResponseUserresponse.data.data 是 User console.log(response.data.data.name); // 自动提示 name 属性 } catch (error) { // error 是 AxiosError可访问 error.response?.data.code } };注意AxiosResponseApiResponseT的嵌套写法确保了response.data的类型是ApiResponseT而不是any。这是很多教程遗漏的关键点。3.4 自定义 Hook 的类型定义让逻辑复用不再“失联”自定义 Hook 是 React 的高级玩法但类型定义稍有不慎就会让使用者一头雾水。核心原则Hook 的返回值类型必须显式声明且尽可能解构为具名对象。// hooks/useForm.ts import { useState, useCallback } from react; // ✅ 推荐返回值类型为具名接口清晰表达每个字段含义 interface UseFormReturnT { values: T; errors: PartialRecordkeyof T, string; handleChange: (name: keyof T, value: any) void; handleSubmit: (cb: (values: T) void) void; } export const useForm T extends Recordstring, any(initialValues: T): UseFormReturnT { const [values, setValues] useStateT(initialValues); const [errors, setErrors] useStatePartialRecordkeyof T, string({}); // Partial 确保 errors 可为空 const handleChange useCallback((name: keyof T, value: any) { setValues(prev ({ ...prev, [name]: value })); // 清除对应字段的错误 if (errors[name]) { setErrors(prev ({ ...prev, [name]: undefined })); } }, [errors]); const handleSubmit useCallback((cb: (values: T) void) { // 简单校验所有字段非空 const newErrors: PartialRecordkeyof T, string {}; Object.keys(values).forEach(key { if (!values[key as keyof T]) { newErrors[key as keyof T] ${key} 不能为空; } }); if (Object.keys(newErrors).length 0) { setErrors(newErrors); return; } cb(values); }, [values]); return { values, errors, handleChange, handleSubmit }; }; // ✅ 使用时解构清晰类型自动推导 const LoginForm () { const { values, errors, handleChange, handleSubmit } useForm({ email: , password: }); return ( form onSubmit{(e) { e.preventDefault(); handleSubmit((data) { // data 类型是 { email: string; password: string } console.log(data.email, data.password); }); }} input value{values.email} onChange{(e) handleChange(email, e.target.value)} / {errors.email span{errors.email}/span} /form ); };4. 常见问题与排查技巧实录那些官方文档不会告诉你的“暗礁”即使严格按照上述步骤操作你在集成 TypeScript 时仍会遭遇一些“幽灵问题”——它们不报错但让你的开发体验大打折扣或者报错信息极其晦涩指向一个完全无关的文件。以下是我在 12 个项目中总结的 5 个高频暗礁及独家排查法。4.1 问题“Cannot find module ‘xxx’ or its corresponding type declarations” —— 类型声明丢失的连锁反应现象明明安装了axios但import axios from axios报错或者安装了types/react-router-dom但useNavigate提示未定义。根因分析这不是模块未安装而是 TypeScript 的模块解析失败。常见于node_modules中存在多个版本的同名包如types/react17.x 和 18.x 共存tsconfig.json的baseUrl和paths配置错误导致路径别名解析失败package.json的type: module与 CommonJS 模块混用独家排查法三步定位检查类型包版本一致性运行npm ls types/react types/react-dom确保输出只有一个版本。如果有多版本执行npm dedupe或手动npm install types/react18.2.72 types/react-dom18.2.22版本号与 React 主版本严格匹配。验证路径别名在tsconfig.json中找到compilerOptions.paths然后在终端执行npx tsc --traceResolution查看 TS 解析模块的详细日志确认paths是否被正确应用。临时关闭模块解析在tsconfig.json中添加moduleResolution: node显式指定并删除baseUrl和paths测试是否还报错。如果消失说明是路径配置问题。实操心得我们团队在 CI 流程中加入了一条检查脚本每次 PR 提交时自动运行npm ls types/react如果检测到多个版本直接阻断合并。这避免了 80% 的“类型丢失”问题。4.2 问题IDEVS Code类型提示失效但tsc --noEmit编译通过现象VS Code 里useState的setCount方法没有参数提示props的属性不自动补全但终端运行tsc却没有任何错误。根因分析VS Code 的 TypeScript 语言服务与项目本地的 TS 版本不一致。VS Code 内置了一个 TS 版本但它可能比你package.json中指定的版本旧或新导致类型服务无法正确加载项目配置。独家排查法两招必杀强制 VS Code 使用工作区 TS 版本在 VS Code 中按CtrlShiftPWindows/Linux或CmdShiftPMac输入TypeScript: Select TypeScript Version选择Use Workspace Version。这会让编辑器使用node_modules/typescript中的版本。重启 TS 服务在 VS Code 中按CtrlShiftP输入TypeScript: Restart TS Server。这会清除语言服务的缓存强制重新加载tsconfig.json。实操心得在团队README.md中我们明确要求新成员安装插件TypeScript Hero它能自动检测 TS 版本不匹配并弹出提示比手动操作快 5 倍。4.3 问题useState初始值推导错误导致后续setState报错现象const [items, setItems] useState([]); // TS 推导 items: never[]setItems: React.DispatchReact.SetStateActionnever[] // 当执行 setItems([{ id: 1 }]) 时报错Type { id: number; }[] is not assignable to type never[]根因分析空数组[]的类型是never[]这是一个“空类型数组”表示“永远不会有元素”。TS 无法推导其未来会存放什么类型。独家解决方案四选一首选显式泛型useStateItem[]([])次选使用as const断言useState([] as Item[])规避初始化为null或undefined并在组件内做空值判断const [items, setItems] useStateItem[] | null(null)终极在tsconfig.json中添加noImplicitAny: false不推荐破坏严格性实操心得我们团队在 ESLint 规则中加入了typescript-eslint/no-inferrable-types它会警告所有可推导类型的显式声明如let a: number 1但对useState([])这种危险推导却放行。因此我们额外添加了自定义规则当useState的参数是[]、{}、null时强制要求显式泛型。4.4 问题第三方库如 Ant Design的类型定义不完整Button typeprimary报错现象Ant Design 的Button组件type属性只允许default | dashed但文档明确写了primary是合法值。根因分析第三方库的类型定义types/antd滞后于实际组件实现。Ant Design 更新了组件逻辑但类型声明文件未同步更新。独家解决方案三步走临时覆盖在项目根目录创建src/typings/antd.d.ts写入declare module antd { interface ButtonProps { type?: primary | dashed | link | text | default; } }提交 PR前往DefinitelyTyped仓库https://github.com/DefinitelyTyped/DefinitelyTyped为types/antd提交类型修正 PR。我们已为 Ant Design、Lodash、Axios 等库提交过 17 个 PR其中 12 个被合并。长期策略在package.json的devDependencies中将types/antd的版本锁定为^5.12.0已知兼容的最新版避免自动升级到有问题的版本。实操心得我们维护了一个内部types/internal-fixes包专门存放所有第三方库的类型补丁。新项目初始化时直接npm install types/internal-fixes省去每个项目重复造轮子。4.5 问题useEffect依赖数组报错 “React Hook useEffect has a missing dependency”但添加后导致无限循环现象const [count, setCount] useState(0); useEffect(() { const timer setTimeout(() { setCount(c c 1); // 闭包捕获旧 count }, 1000); return () clearTimeout(timer); }, []); // ESLint 报错count 未在依赖中 // 如果添加 count 到依赖}, [count])则每次 count 变化都触发 effect无限循环根因分析这是 React Hooks 的经典闭包陷阱。setCount(c c 1)是函数式更新不依赖外部count但 ESLint 无法智能识别这种模式。独家解决方案两招正确写法推荐使用函数式更新依赖数组保持为空[]并在 ESLint 注释中说明useEffect(() { const timer setTimeout(() { setCount(c c 1); }, 1000); return () clearTimeout(timer); }, []); // eslint-disable-line react-hooks/exhaustive-deps替代写法复杂场景用useRef存储最新值避免闭包const countRef useRef(count); countRef.current count; // 每次 render 更新 ref useEffect(() { const timer setTimeout(() { setCount(countRef.current 1); // 读取 ref 中的最新值 }, 1000); return () clearTimeout(timer); }, []);实操心得我们团队禁用了react-hooks/exhaustive-deps规则改用eslint-plugin-react-refresh它能更精准地识别函数式更新场景避免误报。5. 生产环境加固从开发体验到线上监控的全链路类型保障TypeScript 的价值不仅体现在开发阶段更延伸至生产环境。一个真正成熟的 TSReact 项目应该让类型安全贯穿从本地编码、CI/CD 构建、到线上错误监控的全生命周期。5.1 CI/CD 流水线中的类型检查让“编译失败”成为第一道防线很多团队只在本地运行tsc这远远不够。我们必须在 CI 流水线中强制执行类型检查确保任何绕过本地 IDE 的代码如直接 push 到 GitHub都无法通过构建。GitHub Actions 示例.github/workflows/ci.ymlname: CI on: [push, pull_request] jobs: type-check: runs-on: ubuntu-latest steps: - uses: actions/checkoutv3 - name: Setup Node.js uses: actions/setup-nodev3 with: node-version: 18 - name: Install dependencies run: npm ci - name: Type Check # 关键使用 --noEmit只检查类型不生成 JS 文件 run: npx tsc --noEmit --project tsconfig.json # 如果类型错误此步骤失败整个 CI 中断关键配置说明--noEmit只进行类型检查不生成任何输出文件避免污染构建产物。--project tsconfig.json显式指定配置文件防止因工作区多 tsconfig 导致误用。在package.json中添加scriptstype-check: tsc --noEmit方便本地快速验证。实操心得我们曾遇到一个严重事故开发者本地 VS Code 类型服务异常未报错但tsc --noEmit实际报错。由于 CI 未启用类型检查错误代码被合入主干导致线上一个关键页面白屏。自此我们将type-check设为 CI 的准入门槛任何 PR 必须通过才可合并。5.2 运行时类型校验Zod React Query 的黄金组合TypeScript 的类型检查只在编译期有效。如果后端返回了不符合预期的数据如字段名拼写错误、类型不匹配前端依然会崩溃。这时需要运行时校验。我们采用Zod轻量、零依赖、类型推导强大 React Query自动缓存、请求状态管理的组合// schemas/user.ts import { z } from zod; export const UserSchema z.object({ id: z.string(), name: z.string().min(1), email: z.string().email(), createdAt: z.string().regex(/^\d