Vue3+TypeScript项目类型工具封装实战:从重复类型到类型安全
2026/9/9 15:11:24 网站建设 项目流程

前阵子接手一个跑了两年多的 Vue3 + TypeScript 后台项目,打开代码之后我最头疼的不是组件乱,而是类型定义散落一地。系统里每个模块都有一套类似的接口响应结构,有人管它叫 Res,有人叫 ApiResult,有人干脆不定义直接返回 any。这不是某个新人的问题,而是整个项目压根没沉淀出公共的类型工具层,每个人都按自己的理解写类型,写到后面类型安全就成了一句口号。

这篇文章我想和你聊聊怎么把 Vue3 + TypeScript 项目里的类型工具真正封装起来、沉淀下去。我会从项目里最常见的重复类型场景出发,拆出几个高复用的类型工具,再结合组合式 API 讲清楚它们是怎么落地的,最后把我踩过的坑一并说出来。适合已经能用 TypeScript 写基础类型、但还没系统整理过类型体系的开发者。

1. 先想清楚:类型工具层到底解决了什么问题

从前端业务开发者的视角看,类型系统最直接的价值就一句话:让不可能悄悄发生的事,在编译期就原形毕露。但如果你只是给变量标注了类型,没有一个可复用的类型工具层,项目里照样会冒出无数重复、矛盾、走样的类型声明。

1.1 没有类型工具层的项目是什么样子

我给你描述一个很常见的后台管理系统。用户模块、订单模块、商品模块都要对接分页列表接口,三个模块的开发者各自写了这么一套:

// 用户模块 interface UserPageResult { code: number message: string data: { rows: User[] total: number } }
// 订单模块 interface OrderListResponse { code: number msg: string content: { list: Order[] count: number } }
// 商品模块 const getProductList = async () => { const res = await http.get('/products') return res.data.rows }

同一个系统里,接口响应字段命名不统一、字段结构不统一,有的甚至直接把 res.data 当成业务数据用。一旦后端调整统一响应结构,前端要改的地方是全局搜索、逐个人工判断。

这是最典型的"没有类型工具层"的症状:类型定义散落在各业务模块里,没有统一出口,没有公共抽象,没有变更传播能力。你改一个后端字段,类型系统根本不会告诉你哪里受影响,因为到处都是各自为政的重复声明。

1.2 类型工具层和普通类型定义的区别

我给团队讲这个概念的时候用的类比是"普通类型是数据,类型工具是函数"。

普通的接口声明、type 别名定义,是类型空间里的"数据"。比如你定义一个User接口,它描述了用户实体长什么样,但它不具备生产能力。而类型工具是类型空间里的"函数"——你给它一个类型,它通过条件类型、映射类型、模板字符串类型这些手段,产出另一个更贴合场景的类型。

举个例子。User是实体类型,但业务上你还需要"新增用户的表单模型"、"编辑用户的表单模型"、"用户查询条件模型"。这三个模型都和User有关,却不完全一样。如果你为每个场景手写一份类型,将来实体加字段,三个模型全要改;如果你用类型工具从User派生,改实体一处,后面的全部联动。

type User = { id: number username: string nickname: string email: string status: number createdAt: string } type UserCreateForm = Omit<User, 'id' | 'createdAt'> type UserEditForm = RequiredBy<UserCreateForm, 'id'> type UserQueryForm = DeepPartial<UserCreateForm>

这里的RequiredByDeepPartial就是类型工具。它们不是业务类型,但负责从业务类型里生产出符合场景的新类型。这一层抽象,就是 TypeScript 类型安全在大型项目里能不能长期维持的分水岭。

1.3 封装类型工具的收益边界

也别把类型工具想得越复杂越好。我见过有些人把类型体操写到五层嵌套,结果一个字段加进来要改三个地方,维护成本比不用类型还高。我的判断标准是看业务变动频率和出错成本

接口响应结构、枚举状态、表单模型、组件 Props/Emits 边界,这几个地方在后台项目里几乎天天动,而且改错了影响面巨大,值得优先封装。而像某一页里的一次性临时数据结构,直接写在页面文件里就好,没必要为它建工具、抽目录,那样反而多一层跳转成本。

类型工具的导出也不宜贪多。只导出真实项目里用到的,而不是把网上收集的几十个高级工具全堆进去。工具越多,选择成本越高,最后谁都不愿意用。

2. 动工之前的环境与组织约定

在写任何类型工具之前,我建议先把项目里的类型文件组织方式和 TS 配置理一理。这一步被很多人跳过,结果就是类型工具写好了但没人知道去哪引用、工程里各种路径 alias 解析不出来,体验非常割裂。

2.1 类型文件放哪里:从 types 目录到全局声明

现在的 Vue3 + TypeScript 工程,我比较推荐在src下建一个types目录,按职责拆文件,然后统一出口。

src/ types/ index.ts // 统一导出 api.ts // 接口响应结构、请求相关工具 entity.ts // 业务实体类型 utils.ts // 通用类型工具

index.ts的做法通常是:

export * from './api' export * from './entity' export * from './utils'

这样其他代码引用时只需要import type { User, ApiResponse } from '@/types',不需要关心具体在哪个文件。以前我见过有人把类型分散在各个页面目录里,结果类型之间互相引用经常出现循环依赖,编译器解析顺序稍微一变就报错。统一出口能从根上缓解这种问题。

这里还要强调一个细节:正常情况下不要在业务组件里直接写interface。我看到很多项目的.vue文件里放了一堆接口定义,一个组件几百行,一大半是类型。组件里只保留组件自己的 Props/Emits 声明,凡是可复用的实体类型、接口响应类型,尽量下沉到types目录。这样组件文件的职责才纯粹。

2.2 tsconfig 里的几个关键开关

tsconfig 配置直接影响类型工具能不能正常工作,有三个点要特别留意。

一是strict必须打开。类型工具依赖的是完整的类型推导,如果strict关掉,nullundefined都不校验,很多类型工具的价值直接减半。新项目基本都开了,老项目要迁的话建议尽早处理,否则你在封装类型工具时总是被隐式 any 打断推导链。

二是paths配置。很多项目是从旧版 Vue CLI 迁移来的,tsconfig 里长这样:

{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }

这个写法在当前 TypeScript 版本里会看到一条弃用警告:选项 "baseUrl" 已弃用,并将停止在 TypeScript 7.0 中运行。TypeScript 5.0 之后,paths已经可以在不设置baseUrl的情况下使用,所以正确做法是删掉baseUrl,把 paths 改成相对路径:

{ "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }

这个改动不大,但能让你在下一个大版本升级时少踩坑。我见过很多人遇到这个警告直接忽略,等到升级 TypeScript 7.0 再处理,那时候全工程的 alias 解析可能直接崩。

三是 Vue3 项目建议在 tsconfig 里补上vueCompilerOptions的相关配置。这个选项不同版本细节不同,但核心思路是让编辑器能够按 Vue 单文件组件的规则去解析类型,尤其是模板里的类型检查。之前很多人吐槽"模板里写错类型不报错",多半就是没配置到位。

2.3 as const 与 typeof 的配合:类型空间的取数方式

TypeScript 里类型空间和值空间是两个世界。普通对象字面量是"值",typeof可以把值的结构变成类型;as const可以让对象属性收窄成字面量类型。这两个操作组合起来,是类型工具封装里最常用的取数方式。

举个例子,项目里经常会有一组常量配置:

export const UserStatusMap = { DRAFT: 0, ACTIVE: 1, BANNED: 2, } as const

如果不加as constUserStatusMap.ACTIVE的类型是number,你要把它当成枚举字面量来用就必须手动声明联合类型。加上了as const之后:

type UserStatus = typeof UserStatusMap[keyof typeof UserStatusMap] // 推导结果:0 | 1 | 2

以后后端加一个状态,你只需要在这个对象里加一个 key,UserStatus自动跟着变。这就是单点维护的威力。

同时你还会拿到keyof typeof UserStatusMap,这一下就把对象的所有键变成了联合类型'DRAFT' | 'ACTIVE' | 'BANNED'。配合模板字符串类型,还能生成带前缀的事件名、带后缀的缓存 key 等,非常实用。

3. 从真实业务里抽出的五个高复用类型工具

下面这些类型工具是我在 Vue3 后台项目里沉淀下来、几乎每个项目都能直接用的。每一个都对应一个具体的重复劳动场景,封装之后代码量和心智负担都有明显下降。

3.1 响应包裹类型:ApiResponse<T> 与 Unwrap

绝大多数中后台项目的接口响应都遵循统一的包裹结构:

export interface ApiResponse<T> { code: number message: string data: T }

在封装 axios 实例时,泛型可以直接把业务数据类型透传出去:

import axios, { type AxiosRequestConfig } from 'axios' const http = axios.create({ baseURL: '/api' }) export function request<T>(config: AxiosRequestConfig): Promise<T> { return http.request<ApiResponse<T>>(config).then((res) => { if (res.data.code !== 0) { throw new Error(res.data.message) } return res.data.data }) }

调用端就能拿到非常干净的类型:

const userList = await request<User[]>({ url: '/users', method: 'GET' }) // userList 的类型是 User[]

有了这个基础,再配合一个Unwrap类型工具,能从"整个响应类型"里抽出业务数据层。这个工具在写高阶逻辑、二次封装请求能力时很管用:

export type UnwrapApi<T> = T extends ApiResponse<infer D> ? D : T

比如你写了一个返回整个 Promise 链路的组合式函数,想提取其最终数据层类型,就可以用它。这类小工具单独看不值钱,但项目里统一用起来之后,接口层的类型语义会清晰很多。

3.2 Partial 的局限与业务化改写

TypeScript 内置的Partial<T>在真实业务里经常不够用,因为它只处理一层,而且它作用于所有属性、没有选择性。表单编辑场景就是最典型的反例:新增时没有 id,编辑时 id 必填,查询时全部可选。

我项目里最常用的两个工具:

export type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>> export type OptionalBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>

用法:

type UserEditForm = RequiredBy<UserCreateForm, 'id'> type UserSearchForm = OptionalBy<UserEditForm, 'status'>

RequiredBy的语义是"挑出某些字段设为必填,其余不变",OptionalBy则相反。这个表达比手写Omit & Pick可读性好太多,代码生成类型文档的时候,看名字就知道用途。

再有就是深层的可选处理。搜索场景通常需要递归把所有嵌套对象都变成可选的,内置Partial只做一层,深对象就得靠自定义工具:

export type DeepPartial<T> = { [K in keyof T]?: T[K] extends (...args: any[]) => any | Date | RegExp ? T[K] : T[K] extends object ? DeepPartial<T[K]> : T[K] }

这里有个细节很多人会踩坑:如果T的属性是Date,而你的判断只写T[K] extends objectDate也会被递归展开,结果你会拿到一个完全没有方法的Date结构。所以必须显式排除函数、DateRegExp这类特殊对象。这类细节属于"运行时没人教、类型工具踩一次才长记性"的典型。

3.3 用 as const 对象提取枚举联合类型

现在 Vue3 + TS 的项目里,我已经基本不用enum了。不是因为 enum 有大错,而是在模块化和编译配置越来越丰富的今天,const enum会和isolatedModules冲突,数字枚举的反向映射在 tree shaking 时代也容易留下杂质。更主流的做法是"常量对象 + 类型提取"的组合。

export const UserStatusMap = { DRAFT: 0, ACTIVE: 1, BANNED: 2, } as const export type UserStatus = typeof UserStatusMap[keyof typeof UserStatusMap] export type UserStatusKey = keyof typeof UserStatusMap

这套写法的好处是值空间和类型空间是同一份数据源。页面上渲染下拉选项时,动态遍历UserStatusMap;TS 类型校验时,取UserStatusKey。一个状态改了,选项和类型同步更新。

对比直接用 enum 的情况:

enum UserStatus { DRAFT = 0, ACTIVE = 1, BANNED = 2, }

enum 同时也存在于值空间,但迭代时要用Object.values(UserStatus)在某些配置下会拿到反向映射的额外内容,类型也不能直接通过keyof拿到干净的键集合。反而是 as const 对象的方式更直观可靠。

3.4 条件类型的分发映射:状态码转语义标签

后端下发的状态码通常只是数字,但页面显示需要对应的中文标签。这种情况我既会写一个运行时映射对象,又会写一个同名的类型映射,让类型层面的语义和业务展示保持一致。

运行时映射:

export const UserStatusText: Record<UserStatus, string> = { 0: '草稿', 1: '正常', 2: '已封禁', }

类型层面,可以用条件类型把状态码映射成语义化的字面量类型:

export type UserStatusLabel<T extends UserStatus> = T extends 1 ? '正常' : T extends 2 ? '已封禁' : '草稿'

然后你的表格列数据里,类型提示会跟着状态值自动收敛:状态是1时,关联的文本类型就是'正常',不是string,写代码时能获得精确的自动补全。如果你在业务代码里不小心把一个状态判断分支写反了,类型检查会直接给到提示。

这里有一点要说清楚:TS 类型只是编译期行为,运行时不可能拿UserStatusLabel去转换数据,最终给用户看的还是要用UserStatusText那个对象。类型工具负责的是开发期的"防呆",运行时映射负责的是真正的数据转换,两者并存,角色不同。

3.5 组件 Props 与 Emits 的静态提取

Vue3 单文件组件里,definePropsdefineEmits是组件对外的类型边界。但在二次封装组件时,外层组件经常需要把内层组件的能力原样透传出去,这时如果不能静态提取内层 Props/Emits 类型,就只能逐个手动声明,一旦上游组件加了字段,外层组件就悄悄失联了。

Vue 3.3 之后,我们可以用ComponentProps来做静态提取:

// UserForm.vue const props = defineProps<{ id?: number initialData?: UserCreateForm }>() const emit = defineEmits<{ 'update:visible': [visible: boolean] submit: [formData: UserCreateForm] }>()

外层封装时:

import UserForm from './UserForm.vue' import type { ComponentProps } from 'vue' type UserFormProps = ComponentProps<typeof UserForm>

拿到UserFormProps之后,外层组件的defineProps<UserFormProps>()就能直接获得与内层一致的 props 提示。Emits 同理,通过实例类型提取:

type UserFormEmits = InstanceType<typeof UserForm>['$emit']

这套做法省下的不仅是重复声明,更重要的是上游组件类型一变,下游编译立刻报错。类型安全的意义就在这种边界上体现得特别充分——它不是给你欣赏的,是在你改漏的时候拦住你的。

4. 在 Vue3 组合式 API 里把类型安全真正落地

类型工具说到底是要服务于实际代码的。这一节我挑几个最常见的落地场景,看看这些工具是怎么和组合式 API、组件设计融到一起的。

4.1 useRequest 泛型封装:数据、错误、加载态全程带类型

后台项目里大量代码是"调接口——维护 loading、error、data——再赋给页面"。如果不封装,每个页面都要写一遍类型注解。用泛型封装一个useRequest,可以把请求的数据类型保持在整个生命周期里:

import { ref, shallowRef, type Ref } from 'vue' export function useRequest<T>( fetcher: () => Promise<T>, options?: { immediate?: boolean } ) { const loading = ref(false) const error = shallowRef<Error | null>(null) const data = ref<T>() as Ref<T | undefined> async function run() { loading.value = true error.value = null try { data.value = await fetcher() } catch (e) { error.value = e instanceof Error ? e : new Error(String(e)) } finally { loading.value = false } } if (options?.immediate !== false) run() return { loading, error, data, run } }

这里有个细节我刻意写了:data的类型是Ref<T | undefined>,而不是Ref<T | null>。这样在外面使用时,判断是否存在就是if (data.value),而不是if (data.value !== null),也不需要用非空断言,整体代码干净很多。

使用方式:

const { data, loading, run } = useRequest(() => request<ReplyList<User>>({ url: '/users', method: 'GET' }) )

data.value?.rows的类型是User[] | undefinedloadingRef<boolean>run是异步函数。这一整套类型全部由泛型自动推导,页面里基本不需要手写一行类型注解。

4.2 表单模型从实体 DTO 自动派生

表单在后台项目里的地位不用多说。类型工具在这里最大的作用,是让"实体 DTO、新增表单、编辑表单、查询表单"形成一套派生链。

type User = { id: number username: string nickname: string email: string status: UserStatus createdAt: string } type UserCreateForm = Omit<User, 'id' | 'createdAt'> type UserEditForm = RequiredBy<UserCreateForm, 'id'> type UserQueryForm = DeepPartial<UserCreateForm>

后端接口加了个字段,不管是新增还是编辑还是查询,只要改实体定义,下面三个表单类型全部自动联动。过去我见过一个项目改实体字段要全局搜"UserForm"、手动改四五处,现在改一处其他地方编译期就能验证。

更重要的是,这些派生出来的类型可以直接喂给组件:

const props = defineProps<{ modelValue: UserEditForm | null }>() const emit = defineEmits<{ submit: [formData: UserEditForm] }>()

整个"实体到表单再到组件边界"的类型链路是通的,中间没有一层 any 或手工定义,出问题的概率自然就低。

4.3 组合式函数的返回类型:用 ReturnType 避免二次声明

组合式函数越来越多之后,会碰到一个情况:某个函数从父组件传进子组件,或者多个函数之间共享返回值类型。手工去维护一份"返回类型接口"通常很痛苦,因为组合式函数的返回结构一旦调整,接口声明很容易忘记同步。

ReturnType是类型空间里的一个内置工具,配合typeof可以直接从函数类型推导返回类型:

export function useUserTable() { const { data, loading, run } = useRequest(() => request<ReplyList<User>>({ url: '/users', method: 'GET' }) ) async function refresh() { await run() } return { data, loading, refresh } } type UseUserTableReturn = ReturnType<typeof useUserTable>

如果另一个组合式函数需要以它为参数,直接写:

export function subscribeTable(table: UseUserTableReturn) { watch(table.data, (val) => { /* ... */ }) }

这样就不用再去手动 declare 一份重复的结构。组合式函数的类型从"手写维护"变成"自动推导",从根上避免了不同步的问题。

4.4 二次封装组件时的 Props/Emits 透传

实际项目里"封装带搜索条件的用户弹窗"、"封装带权限校验的表格"这类需求特别多,本质都是在一个基础组件外面套一层逻辑。如果不提取类型,外层组件的 Props 就退化成手工声明:

const props = defineProps<{ visible: boolean user?: UserEditForm | null // 内层组件已经有 8 个 props,这里忘了几个也看不出来 }>()

有了ComponentPropsInstanceType<...>['$emit']之后,外层可以直接引用内层类型:

import UserForm from './UserForm.vue' import type { ComponentProps, DefineComponent } from 'vue' type UserFormProps = ComponentProps<typeof UserForm> type UserFormEmits = InstanceType<typeof UserForm>['$emit'] const props = defineProps<UserFormProps>()

函数组件、类组件、单文件组件都适用这套方式。这个封装的收益不是写起来少几行,而是"类型边界跟随组件实现自动漂移"——你改了内层组件的 props,外层封装不用动任何代码,编译时就能感知。

5. 我踩过的坑和最后几条建议

类型工具封装这件事,理论上讲久了容易飘,真正落地的时候全是一个一个坑堆出来的经验。这里把我踩过的几个比较要命的问题说透。

5.1 不要用 any 兜底:unknown 才是安全的垫脚石

写类型工具的时候,图省事就写 any 的人不在少数。但 any 有一个致命特性:它会打断整个类型推导链。你某个类型工具返回值是 any,那么下游所有从这个工具拿类型的地方全部失去保护。

举个例子,你封装请求的时候如果写过:

return res.data.data as any

那外面调request<User[]>得到的 User[] 实际是伪造的——中间环节已经 any 了,编译器根本不能证明这个类型成立,后续所有基于 User[] 的操作,类型系统都帮不上忙。

正确姿势是用unknown收底,再用类型收窄把它慢慢收敛成确定类型:

function normalizeData(value: unknown): User[] { if (Array.isArray(value)) { return value.filter((item): item is User => typeof item === 'object' && item !== null) } return [] }

类型工具封装的底线是:要么推导出精确类型,要么用 unknown 让使用者明确知道这里不可靠,绝不用 any 假装一切正常。

5.2 映射类型里的可选属性:? 的语义陷阱

DeepPartial这类深递归工具,会把所有层级的属性都变成可选。这在搜索表单里是合理的,但在一些"第一层可选、第二层值还是必填"的嵌套结构里就是灾难。

假设这样一个接口类型:

type SearchParams = { page: number filters: { keyword: string status: UserStatus } }

你期待的是"填了 filters 就必填 keyword 和 status",但DeepPartial<SearchParams>会允许{ filters: {} }通过编译。如果你的业务逻辑不允许"空 filters 对象",这个类型就不够安全。

我在项目里的处理方式是把深浅两层拆成两个工具来用,按需组合:

type SearchParamsQuery = Partial<SearchParams> & { filters: RequiredBy<SearchParams['filters'], 'status'> }

类型工具的语义没有银弹,最危险的是"看起来对了但边界不对"。封装者必须把每个工具的边界条件在注释里写明白,否则用的人会默认它万能。

5.3 条件类型分发要包一层

条件类型有一个容易被忽略的行为:当泛型参数是裸类型参数时,条件类型会针对联合类型的每个成员分别做判断,这个叫"分发"。有时候这是好事,比如DeepPartialUnion就需要分发;但有时候会带来完全意料之外的结果。

最常见的坑是判断"是否 never":

type IsNever<T> = T extends never ? true : false type A = IsNever<never> // 期望 true,实际得到 never

原因是never在条件类型分发时被当成"空的联合类型",条件判断根本不会执行,直接返回never。解决办法是把泛型参数包进元组,停止分发:

type IsNever<T> = [T] extends [never] ? true : false type A = IsNever<never> // 正确得到 true

我实际项目里踩过一次这种坑,是一个从"后端返回的类型可能为 never"的场景里做兜底判断,结果分支逻辑完全反了。排查了挺久才定位到是分发问题。所以只要你的条件类型输入有可能是联合类型、never、或布尔值这类可分发类型,都建议把裸参数包进[]里再判断,避免意外。

5.4 vue-tsc 构建检查与 Volar 的配置细节

最后一个坑不在类型工具本身,而在工程链。TypeScript 的类型检查命令默认只检查.ts文件,.vue文件里的<template><script setup>对应的是 Vue 自己的编译器视图。如果你只在 IDE 里看到类型错误,构建时却没有拦截,那 CI 基本形同虚设。

我在项目里会加一条脚本:

{ "scripts": { "type-check": "vue-tsc --noEmit" } }

CI 里每次提交前跑一遍,vue-tsc会把 SFC 里的模板类型也纳入检查。比如你在模板里写了{{ user.name.toFixed() }},而user.namestring,这种错误在构建阶段就能被拦下,而不是等线上报错。

Volar 插件这块也要注意版本变化。新版本的 Volar(Vue Language Features)已经推荐直接以 TS 插件方式工作,不需要再手动开启旧的 takeover 模式。如果你用旧配置突然发现类型检查失效,先检查 Volar 版本和启用方式,再查 tsconfig 里的vueCompilerOptions.strictTemplates,这个配置决定模板里是否做更严格的类型校验。

围绕 Vue3 + TypeScript 的类型工具封装,我的原则一直很朴素:只在业务变动最频繁、出错成本最高的接口响应、表单模型、枚举转换、组件边界这几个位置做抽象,剩下的保持简单。类型工具是工具箱,不是收藏架,写得多不如用得稳。把这几个位置的类型链路打通之后,你会明显感觉到重构的胆子大了、改字段的心理负担轻了,这就是类型安全真正进入状态的样子。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询