
简介一套面向TypeScript深度学习者与Vue3全栈开发者的原创源码合集聚焦高级类型编程、装饰器、全局声明文件等核心难点通过大量手写示例讲解条件类型、映射类型与装饰器实现原理并配合一套基于Vue3、TypeScript与Pinia的基础后台管理项目帮助读者打通从类型理论到工程落地的全链路。压缩包共15个文件以7个ts类型练习文件、4个json工程配置和2个vue组件为主辅以1个js工具脚本与1个md使用说明整体仅8KB体量轻巧但覆盖完整。工程配置方面提供Vite构建配置、tsconfig编译选项、Turborepo仓库管理、ESBuild打包脚本及VSCode调试配置所有代码均为手动原创编写注释完整、目录层次清晰并附带详细安装与运行教程从环境准备到执行命令均有说明直接对照即可上手。已有41人学习下载适合前端进阶、TypeScript专项突破及Vue3项目开发时反复参考尤其适合想要补强类型编程能力的中高级开发者。1. 先设计类型再写页面全栈项目的第一行代码应该是什么去年做全栈项目时我被一个字段名坑惨了。后端接口返回的是user_id前端代码里写的是userId直到页面白屏、控制台报错才查出来。后来我痛定思痛在重构整个项目时立了一条规矩先用 TypeScript 类型系统把前后端契约定下来再写任何业务代码。这是我在 Vue3 全栈项目里贯彻最彻底的一件事也是我想通过这套原创源码跟你分享的核心经验。这篇文章会从类型系统设计、Vue3 前端接住类型、后端校验取舍、源码目录组织几个部分展开。项目本身是一套包含用户认证、动态路由、订单管理的全栈应用前端是 Vue3 Pinia Vite后端是 Node.js Prisma中间用共享类型包把两边串起来。无论你是准备从零搭建全栈项目还是已经被前后端类型失配折磨过这篇文章都能给你一个可落地的参考。1.1 接口联调的最大成本字段名和结构的漂移前端和后端由同一个人写是不是就不需要类型契约了我的答案是恰恰相反。全栈开发最大的幻觉就是“反正都是我写的我肯定记得”。实际过两周再回来看接口返回里的createdAt和页面里使用的publish_time已经对不上了问题要到接口返回那一刻才暴露。这种问题在 JS 项目里特别隐蔽。前端拿到一个对象想当然地取res.data.user.name结果接口返回的结构是res.data.userInfo.nickname运行时直接Cannot read properties of undefined。排查成本不高但架不住项目模块多了之后隔三差五来一次。TypeScript 类型系统解决的就是这件事把字段名、嵌套结构、可空性全部前置到编译期。我在项目里定义类型不是按“前端页面”来拆的而是按“业务实体”来拆。比如User这个核心类型在shared包里定义好之后后端返回结构必须符合它前端取数据也必须从它上面取任何一边偏离编辑器里立刻出现红色波浪线。联调这件事从“对着接口文档反复确认”变成了“类型检查通过就基本没问题”。1.2 用类型定义代替接口文档接口文档的问题不是没人写而是没人维护。后端改了个字段名文档忘了更新前端照着旧文档开发到联调自然炸。倒不是说文档完全没用而是把它作为唯一的事实来源太脆弱。我把类型定义当成了“可执行的接口文档”因为类型不更新编译就过不了。比如最基础的接口包装类型export interface ApiResponseT { code: number; message: string; data: T; } export interface PageResultT { list: T[]; total: number; page: number; pageSize: number; }有了这两个基础类型我定义一个“获取用户分页列表”的接口时只需要写清楚真正的业务类型export interface User { id: string; nickname: string; email: string; role: admin | editor; status: active | disabled; createdAt: string; } export interface QueryUserParams { page: number; pageSize: number; keyword?: string; role?: User[role]; }后端在做接口返回时只要把数据构造成ApiResponsePageResultUser类型检查就会逼着我把total、list这些字段补齐。前端调用时同样通过ApiResponsePageResultUser做泛型推导res.data.list里的每一项都能拿到nickname、status等提示。接口文档当然可以继续写但它已经从“操作手册”降级成了“概要说明”真正精确的东西由类型定义守着。1.3 为什么我把类型定义放在独立目录而不是散落在页面里刚开始写前端时我喜欢在页面文件顶部随手写一个interface Props用完就扔。这在单页面组件里问题不大但全栈项目不一样同一个User类型后端要用前端的 store 要用表格组件要用权限判断也要用。如果每个文件都重新定义一份一旦要加一个字段你就得全局搜索然后一个个改漏了一个就是线上事故。这套源码里我把所有跨端共享的类型放进了packages/shared包。前端apps/web引入它后端apps/server也引入它。单向依赖清晰明了apps/ web/ src/ api/ views/ store/ server/ src/ routes/ services/ packages/ shared/ src/ types/ user.ts order.ts api.ts route.ts这么做最直接的好处是改类型只改一处。后端实体字段调整后先改 shared 里的 DTO 类型前端编译立刻跟着报错逼着我去同步修改所有使用点。这种“一处修改、全局响应”的体验比任何代码 review 都高效。2. TypeScript的类型能力从入门到够用以一个用户列表模块为例很多人对 TypeScript 类型系统的印象还停留在接口和泛型觉得无非就是给变量加个类型注解。但真正让类型系统有威力的是组合能力。一个用户列表模块看起来简单实际上会涉及分页、筛选条件、接口返回、表格渲染、状态更新这些场景全都可以被类型“串”起来让错误在编译期露馅。2.1 泛型、条件类型和工具类型怎么组合才不炫技我见过一些同学把类型写得像天书满屏infer、递归条件类型看得人头皮发麻。说实话全栈项目里 90% 的场景用不到那种复杂技巧更需要的是把基础能力组合好。以用户列表为例前后端分页结构几乎是一样的。前端请求接口拿到数据后经常要透传给表格组件。如果每个组件都重新声明一遍“列表接口返回结构”重复代码就会失控。我一般这样处理export type ListApiT ApiResponsePageResultT; export type UserListApi ListApiUser;这样UserListApi就直接描述了这个接口的完整返回结构。接口层的请求函数再结合工具类型把泛型推导发挥到极致export async function fetchUserList(params: QueryUserParams): PromiseUserListApi { const res await http.get(/user/list, { params }); return res.data; }在组件里我经常需要拿某个接口的返回类型定义局部变量。这时不需要重复写出完整结构直接type UserList AwaitedReturnTypetypeof fetchUserList[data][list];Awaited用于拆开 PromiseReturnType取函数返回值再用索引访问类型取data.list。组合出来的UserList就是User[]。这种写法看起来有一点“体操”但它的每一步都是可解释的而且能够避免数据类型漂移。真正的类型体操是那种为了秀技巧而写出来的巨型类型边界感的判断标准很简单如果一个类型无法一眼看出它想表达什么就应该拆开。2.2 可辨识联合让状态流转在编译期可检查用户列表里最让我头疼的是各种状态字段。status是active | disabled还比较好处理但订单状态这种多阶段、不同阶段带不同附加信息的场景普通枚举根本不够。可辨识联合Discriminated Union是最适合描述这类“不同分支有不同字段”的类型。比如订单状态export type OrderStatus | { status: pending; paidAt?: never } | { status: paid; paidAt: string; payMethod: string } | { status: cancelled; reason: string };在 switch 里处理这个订单时TypeScript 会自动收窄类型。处理paid分支paidAt一定存在处理pending分支paidAt就不存在。这样写的好处是状态流转的每一种情况都被编译器盯着。少处理了一个分支或者写错字段都会报错。这比单纯靠if加注释靠谱得多。2.3 类型体操的边界可读性比秀操作重要说句得罪人的话类型系统设计得越复杂项目维护成本往往越高。我早期特别喜欢写这种类型type DeepPartialT { [P in keyof T]?: T[P] extends object ? DeepPartialT[P] : T[P]; };看起来很厉害但团队成员未必都理解看代码时需要先在脑子里拆解好久。后来我给自己定了一个规矩任何类型定义的阅读时间如果超过 30 秒它就需要被拆解成更小的命名类型或者写上注释。DeepPartial这类工具在最底层封装一次没问题但业务代码里尽量少直接铺开复杂条件类型。核心诉求是让代码的“意图”清晰而不是让类型系统来炫技。实际项目里的接口类型大部分用interface、type、泛型、工具类型组合就足够了。把这些基础吃透比抄一堆复杂类型模板有用得多。3. Vue3前端如何接住类型红利组合式API、路由与状态管理类型定义得再好前端如果全是any也无济于事。Vue3 的组合式 API 对类型推导非常友好配合script setup之后组件几乎不再需要手写太多类型注解。但用好这个红利有几个关键点很多人容易忽略。3.1 组合式函数里泛型和响应式的配合前端模块我习惯用组合式函数Composable封装业务逻辑比如用户列表。ref配合泛型能让状态类型全局收敛export function useUserList() { const list refUser[]([]); const loading ref(false); const total ref(0); async function load(params: QueryUserParams) { loading.value true; try { const res await fetchUserList(params); list.value res.data.list; total.value res.data.total; } finally { loading.value false; } } return { list, loading, total, load }; }组件里调用useUserList()list.value自动推断为User[]模板里遍历时user.nickname有补全写错字段会直接报警。refUser[]这个泛型比普通ref([])重要得多因为空数组初始值会让类型推导成never[]后面赋值就会一直报错。有时我希望组合式函数更通用比如不只给用户列表用还希望给角色列表、文章列表用。这时可以让组合函数本身带泛型export function useListApiT(fetcher: (params: any) PromiseListApiT) { const list refT[]([]); const total ref(0); async function load(params: any) { const res await fetcher(params); list.value res.data.list; total.value res.data.total; } return { list, total, load }; }这样useListApiUser(fetchUserList)和useListApiRole(fetchRoleList)就能复用同一套逻辑同时保持类型完整。3.2 动态路由与权限元信息的类型约束做后台管理系统时动态路由是绕不开的。菜单从后端返回前端根据菜单结构动态添加路由。如果没有类型约束meta里的字段拼错很难发现。Vue Router 允许通过模块扩充来约束RouteMetadeclare module vue-router { interface RouteMeta { title?: string; icon?: string; requiresAuth?: boolean; roles?: Role[]; } }这样一来写路由定义时meta: { title: 用户管理, icon: user }就有字段提示。更重要的是动态注册路由时meta的类型也一并被约束了。我封装了一个buildRoutes函数输入是后端菜单数据输出是RouteRecordRaw[]中间做映射时如果漏掉meta.title等必填项编译器会直接提醒。还有一点动态路由组件映射关系很容易出现any。通常用import.meta.glob批量导入视图组件返回类型是Recordstring, () Promiseunknown要小心处理。我会把组件映射明确成const viewModules import.meta.glob(../views/**/*.vue);然后根据菜单里的componentPath去匹配匹配不到时抛错而不是返回any。这样路由跳转后白屏的概率会低很多。3.3 Pinia Store里那些容易变成any的地方Pinia 的类型推导很聪明但你要是不小心它也会变成 any 的温床。最常见的是localStorage取出来的值类型是string | null如果直接赋给token状态会报错很多人的应对是加个as string但这样并不能解决空值的隐患——万一用户清缓存token 变成 null后续请求全部携带null字符串后端一验就炸。我在 store 里一般这样处理function readToken(): string { const token localStorage.getItem(token); if (!token) return ; return token; } export const useUserStore defineStore(user, { state: () ({ token: readToken(), profile: null as User | null, }), actions: { async login(payload: LoginParams) { const res await api.login(payload); this.token res.data.token; this.profile res.data.user; localStorage.setItem(token, this.token); }, async logout() { this.token ; this.profile null; localStorage.removeItem(token); }, }, });readToken函数把原始存储的处理逻辑收拢到一处类型安全不会在 store 里到处出现as string。另一个容易变成any的地方是actions里调用接口后返回值的类型没有绑定。只要 api 函数返回类型写清楚了这里基本不需要手写任何type。4. 后端类型不能只靠TypeScript校验层与ORM层的实战取舍很多前端转全栈的同学刚开始写后端时觉得“反正 TypeScript 会帮我检查类型安全得很”。这个认知相当危险。TS 类型在编译后会被完全擦除运行时根本不存在。数据库入库的字段、外部请求传入的参数全都在编译期之外。所以后端的类型设计必须区分“编译期类型”和“运行时校验”。4.1 ORM实体和前端DTO类型要统一但不能穿一条裤子我在项目里用 Prisma 作为 ORMschema 定义好之后会生成一套完整类型。这套类型和前端共享的User类型非常像但绝不能直接用同一个类型。原因很简单数据库表User里有passwordHash、createdAt等字段其中有些是绝不能返回给前端的。我的做法是分三层数据库实体类型由 Prisma 根据 schema 自动生成服务层内部使用。业务 DTO 类型定义在shared包里描述对外传输的真实结构。服务层返回前做映射把 Entity 转成 DTO不直接返回 ORM 对象。比如用户实体// server/src/entities/user.entity.ts export interface UserEntity { id: string; nickname: string; email: string; passwordHash: string; role: string; status: string; createdAt: Date; }映射到 shared 包里的User// packages/shared/src/types/user.ts export interface User { id: string; nickname: string; email: string; role: admin | editor; status: active | disabled; createdAt: string; }映射代码放在 service 层返回前统一执行。这样前端永远拿不到密码哈希这类敏感字段就算某个接口忘了排除类型报错也会提醒你Entity 不能直接赋给 DTO。这种“类型统一但不能穿一条裤子”的设计是后端安全的一道隐形防线。4.2 运行时校验是另一道防线zod和class-validator怎么选TS 类型管不了运行时。用户提交一个{ email: hello }你定义的email: string在编译期没问题但运行时它就是一个非法邮箱。数据库可不知道你的 TS 类型它只会因为约束冲突报错。所以后端的输入校验必须依赖运行时库。我在这套源码里最终选了 zod。选择它的理由有三个类型和校验共享一套定义z.infertypeof schema可以直接拿到 TS 类型API 设计简洁在轻量级 Node 服务里不依赖装饰器配合 Fastify/Express 都非常顺手。举个例子登录入参校验import { z } from zod; export const loginSchema z.object({ email: z.string().email(), password: z.string().min(6), }); export type LoginInput z.infertypeof loginSchema;在路由层调用const body loginSchema.parse(req.body);如果请求体不合法zod 会抛出带具体字段的错误前端能直接展示。如果你用的是 NestJSclass-validator配合 DTO 类也很自然。选型不必纠结关键是后端入口必须做运行时校验不能依赖 TS 编译期。4.3 Prisma查询结果里的隐式类型Prisma 生成的类型强但include和select组合起来时结果类型推导会变得复杂。比如我查询用户并带出他最近的订单const userWithOrders await prisma.user.findUnique({ where: { id: userId }, include: { orders: { take: 5, orderBy: { createdAt: desc }, }, }, });userWithOrders的类型会自动推导出来但如果不小心在前端或其他模块复用了这个结果类型信息就会散得到处都是。更好的做法是用Prisma.validator提取出独立类型const userWithOrdersArgs Prisma.validatorPrisma.UserDefaultArgs()({ include: { orders: { take: 5, orderBy: { createdAt: desc }, }, }, }); type UserWithOrders Prisma.UserGetPayloadtypeof userWithOrdersArgs;这样UserWithOrders可以在函数返回值里显式声明语义清晰也避免多层隐式推导后编辑器突然卡顿。Prisma 的自动生成类型已经够强但要把它当成项目资产来管理该提取的类型要提取该收敛的类型要收敛。5. 原创源码的目录设计与版本升级避坑这套源码我重构过两次第一次是维护成本失控第二次是工具链升级踩坑。这里把目录设计和版本升级的经验一起分享给你算是比具体代码更重要的一些沉淀。5.1 monorepo目录怎么划分共享类型包是最核心的资产全栈项目用 monorepo 还是分仓库是一个常见争论。我的建议是如果前后端有大量共享类型用pnpm workspace维护一个 monorepo 最省心。分仓库的话改一个User类型要发两个包、更新两个项目的依赖太累了。目录结构我前面已经列过这里补充几个细节。packages/shared里不只有类型还可以放接口路径常量、枚举值、通用工具函数。比如export const API_PATHS { userList: /user/list, userUpdate: /user/update, } as const;这个对象用as const后前端和后端引用时拿到的都是字面量类型拼错路径不存在的场景编译期就暴露了。后端路由注册时也复用同一份常量确保前后端的路径永远一致。还有一个容易忽略的点packages/shared构建配置要简单。它最好直接输出ESM和CJS两种格式供 Vite 和 Node 分别使用。我在第一次搭的时候只输出了 ESM结果 Node 端通过 ts-node 引用时一直出问题。后来改成用tsup同时打包两种格式再配合package.json里的exports字段指定入口前后端才都能顺利引用。5.2 Vite环境变量和tsconfig的“冷热坑”Vite 项目里import.meta.env的类型是内置的但它只认识MODE、BASE_URL这些基础字段。你自定义的VITE_API_BASE_URL不声明类型时拿到的就是any这等于把类型系统漏了个洞。补充类型其实很简单在env.d.ts里定义interface ImportMetaEnv { readonly VITE_API_BASE_URL: string; readonly VITE_MOCK_ENABLED: boolean; } interface ImportMeta { readonly env: ImportMetaEnv; }配置好了之后写import.meta.env.VITE_MOCK_ENABLED才能有补全和类型检查拼错字段名会直接报错。环境变量是最容易被忽略的“边界类型”但它影响所有接口请求地址值得花五分钟处理干净。tsconfig 方面我有几个推荐基础配置strict: true是必须的这不用多说moduleResolution: Bundler在 Vite 项目里能正确识别/别名jsx: preserve或者jsx: react-jsx取决于你是否在 Vue 里使用 JSX。这里特别提醒Vue3 项目如果用 JSX/TSX 写复杂组件tsconfig里不配好 JSX 相关选项编辑器会一片红。我后来专门写了几个.tsx的组件来承载高阶逻辑类型体验非常好但前提是 configure 正确。5.3 升级TypeScript后遇到的baseUrl废弃等问题写这套源码期间我经历过一次 TypeScript 版本大升级最直接的冲击就是baseUrl被标记为废弃。旧 tsconfig 里为了配置路径别名我习惯写{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }新版 TypeScript 明确提示baseUrl会在未来版本移除推荐直接在paths里写相对路径。修改后{ compilerOptions: { paths: { /*: [./src/*] } } }这个改动本身不大但会影响别名解析的基准路径我一开始以为只是删一个字段结果/指向的目录一度偏了编译各种找不到模块。改完以后顺手检查了所有paths是否都写全相对前缀后来就稳定了。另一个升级影响是moduleResolution。旧项目用的是NodeVite 生态更推荐Bundler模式。切换之后package.json里exports字段的解析行为也会变好的一面是能正确识别types入口坏的一面是如果某个旧依赖没有exports字段可能报错找不到类型。我给shared包配好exports之后前后端引用就都正常了。这里想说的是版本升级时别只盯着新功能tsconfig的兼容性调整往往才是隐藏的大头。每次升级后最好先在本地跑一遍全量类型检查和构建确认没有配置层面的问题再继续开发否则很容易出现 editor 不报错、命令行构建报错的诡异情况。这套源码里让我最受益的一条规定就是“类型定义不吝啬、不炫技、不散落”。类型不只是给编辑器看的更是给三个月后的自己看的。定义得越清晰重构时就越有底气。你完全可以从最核心的用户、权限模块开始先把类型契约建立起来再由点及面铺开别试图第一天就把整个系统的类型全部设计完。类型系统是工具不是目的它要服务的是业务本身。本文还有配套的精品资源点击获取