立项第一天定的规矩只有一条:全量 TypeScript。定这个规矩是因为吃过纯 JS 的亏:上一个项目跑到四万行的时候,改一个接口字段名,得全局搜索所有调用处,漏改一处就是线上白屏。评测平台这种前后端接口特别多的系统,类型就是前置的一层测试。下面分享平台对 TS 的全套实践:类型系统、工具、避坑。
一、为什么必须用 TypeScript
JS 项目到 5 万行以上时,重构一个函数要靠”全局搜索同名调用方”,效率断崖式下降。TS 提供的类型系统让 IDE 能在改一处时自动标红所有受影响的地方。平台核心收益:
- 重构安全:函数签名改了,所有调用方立即在 IDE 标红;
- IDE 智能提示:编辑器能自动补全、自动推断属性;
- 编译期错误:常见 typo、未传必填参数、类型不匹配都在编译时发现;
- 文档作用:类型签名就是 API 文档,新人接手快。
四项收益里,重构安全的性价比很高。评测平台有个”模型版本升级”的需求,涉及十来个接口的返回结构调整,全量 TS 让团队在半天内完成迁移,编译期把所有受影响处揪了出来,一次上线没出问题。这笔账算下来,TS 的初始成本早就赚回来了。
二、tsconfig 关键配置
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true, // 严格模式
"noUncheckedIndexedAccess": true, // arr[i] 类型是 T | undefined
"noImplicitAny": true,
"strictNullChecks": true, // null/undefined 严格区分
"esModuleInterop": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["vite/client", "element-plus/global"]
},
"include": ["src/**/*", "src/**/*.vue"]
}
strict: true 是关键,平台从 Day 1 就开。它相当于一次性打开 strictNullChecks、noImplicitAny 等多项严格检查,把大量运行时错误提前到编译期。我的建议是宁可一开始多改几处报错,也不要先关后开——项目大了以后再开 strict,改动面会大到无法收场。noUncheckedIndexedAccess 是 strict 之外我们额外加的,它让数组取值变成 T | undefined,逼着你判空,评测报告里那个榜单下标越界的问题就是这么被提前拦下的。
三、Vue 3 + TS 集成
1. 组件 Props 类型
// 用 type-only defineProps
const props = defineProps<{
list: Item[]
initial?: number
onChange?: (v: number) => void
}>()
2. 组件 Emits 类型
const emit = defineEmits<{
(e: 'change', value: number): void
(e: 'update', item: Item): void
}>()
3. ref 类型
const count = ref<number>(0)
const user = ref<User | null>(null)
const list = ref<Item[]>([])
4. 模板 ref 类型
import { ref } from 'vue'
const inputRef = ref<HTMLInputElement | null>(null)
onMounted(() => {
inputRef.value?.focus() // 注意可能 null
})
5. 路由参数类型
import type { RouteLocationNormalized } from 'vue-router'
const route = useRoute()
const sessionId = computed(() => {
const id = route.params.id
return typeof id === 'string' ? parseInt(id, 10) : 0
})
这几段是 Vue 3 里比较常见的类型写法。模板 ref 那里容易翻车——ref<HTMLInputElement | null>(null) 的 null 必须写,否则类型上认为它永远有值,后面用 ?. 是安全的写法。路由参数的值可能是 string 也可能是数组,typeof 收一下再 parseInt,能少很多线上崩溃。
四、API 客户端类型
平台给每个接口定义请求/响应类型:
// types/api.ts
export interface LoginRequest {
username: string
password: string
}
export interface LoginResponse {
token: string
userId: number
expiresAt: string
}
export interface EvalSession {
id: number
userId: number
question: string
status: 'pending' | 'running' | 'done' | 'error'
items: EvalItem[]
createdAt: string
}
// api/client.ts
export const api = {
post<TReq, TRes>(url: string, data: TReq): Promise<TRes> {
return http.post(url, data).then(r => r.data.data as TRes)
}
}
// 使用
const res = await api.post<LoginRequest, LoginResponse>(
'/api/auth/login',
{ username: 'admin', password: 'xxx' }
)
// res 类型自动推导为 LoginResponse
接口类型定义是平台类型体系的核心。评测会话的状态用了联合类型 'pending' | 'running' | 'done' | 'error',switch 的时候写漏一个分支编译期就会提示,比运行时排查省事得多。这块要跟后端对齐——我们前后端共用一份接口契约生成类型,避免手写类型跟实际返回对不上。
五、类型守卫
// 1) typeof 守卫
if (typeof value === 'string') {
value.toUpperCase() // 这里是 string
}
// 2) instanceof 守卫
if (error instanceof AxiosError) {
console.log(error.response?.data)
}
// 3) 自定义守卫
interface ApiError {
code: number
message: string
}
function isApiError(e: unknown): e is ApiError {
return typeof e === 'object' && e !== null
&& 'code' in e && 'message' in e
}
try {
await api.post(...)
} catch (e) {
if (isApiError(e)) {
ElMessage.error(e.message)
}
}
类型守卫解决的是”unknown 怎么用”的问题。网络请求的 catch 分支拿到的错误类型是 unknown,直接访问属性会被编译拦下。自定义守卫 isApiError 是平台用得比较多的写法,配合响应拦截器,把”后端返回的业务错误”和”网络错误”区分开处理。
六、工具类型
// 1) Partial<T> - 所有字段可选
type UpdateUser = Partial<User>
// 2) Pick<T, K> - 选字段
type UserBrief = Pick<User, 'id' | 'name' | 'avatar'>
// 3) Omit<T, K> - 去字段
type UserWithoutPassword = Omit<User, 'password'>
// 4) ReturnType<T> - 提取函数返回类型
type ApiResponse = ReturnType<typeof api.getUser>
// 5) Awaited<T> - 解 Promise
type User = Awaited<ReturnType<typeof api.getUser>>
// 6) Record<K, V> - 键值对
type ModelMap = Record<string, ModelInfo>
// 7) Required<T> - 所有字段必填
type StrictConfig = Required<Config>
平台对常用类型用这些工具组合,避免重复定义。这里用的比较多的组合是 ReturnType + Awaited:接口函数的返回类型变了,引用它的类型自动跟着变,不用手动维护第二份。用 Omit 去掉 password 字段这种写法,也避免了”多复制一份 User 类型”带来的漂移问题。
七、泛型组件
// 一个通用 List 组件
interface ListProps<T> {
items: T[]
itemKey: keyof T
}
const props = defineProps<ListProps<Model>>()
// 推导 items 元素类型
type Item = typeof props.items[number]
泛型组件让”一个列表组件吃所有模型”成为可能。传进去的 items 类型不同,itemKey 的校验也跟着变。平台把这个用于评测历史、模型列表、用户列表,省掉了三份几乎一样的组件代码。
八、类型与第三方库
// Element Plus 类型增强
import type { ElMessage } from 'element-plus'
// Axios 拦截器类型
import type { AxiosRequestConfig, AxiosResponse } from 'axios'
http.interceptors.response.use(
(response: AxiosResponse) => response.data.code === 0
? response : Promise.reject(response)
)
// Vue Router 路由 meta 类型扩展
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
title?: string
icon?: string
}
}
第三方库的类型声明是平台常踩的雷区。Element Plus 和 axios 自带类型还好,Vue Router 的 RouteMeta 需要自己扩展。declare module 的写法要注意:全项目只在一个文件里声明,重复声明会报错。
九、避免 any 的实战技巧
// ❌ 滥用 any
function process(data: any) { return data.foo }
// ✅ unknown 强制类型检查
function process(data: unknown) {
if (typeof data === 'object' && data !== null && 'foo' in data) {
return (data as { foo: string }).foo
}
}
// ✅ 部分字段确定时用类型断言
const data = await fetch(url).then(r => r.json()) as MyType
// ✅ zod 运行时校验
import { z } from 'zod'
const UserSchema = z.object({
id: z.number(),
name: z.string()
})
const user = UserSchema.parse(await api.get(...))
any 的克制程度直接决定项目类型安全的成色。我们的规则是:代码评审里出现 any 必须有注释说明理由,否则打回。unknown 加类型守卫是首选,zod 用在”边界校验”——后端返回的数据经过 zod parse,类型和运行时双重保证。
十、strict 模式下的常见 TS 错误
开了 strict 之后,报错数量会先涨一波,但大部分是同一类问题。下面这几个错误类型是我们排查频率排在前面的:
| 错误 | 原因 | 解决 |
|---|---|---|
Object is possibly 'undefined' |
数组/对象访问可能越界 | arr[0]?.foo 或显式判空 |
Property 'xxx' does not exist |
字段名写错 | 查类型定义 |
Argument of type 'X' is not assignable to parameter of type 'Y' |
类型不匹配 | 显式转换或改函数签名 |
Cannot find module 'X' |
缺类型声明 | npm i -D @types/X |
'X' is declared but never used |
未使用变量 | _ 前缀或删掉 |
| `Type ‘X \ | null’ is not assignable to type ‘X’` | null 严格区分 |
前两类占掉大半的报错量。习惯之后会发现它们是类型系统在提醒你”这里有隐藏的分支没处理”,逐个修完,代码质量上了一个台阶。
十一、ESLint + Prettier
// .eslintrc.cjs
{
"extends": [
"eslint:recommended",
"plugin:vue/vue3-recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": ["error",
{ "argsIgnorePattern": "^_" }],
"@typescript-eslint/explicit-function-return-type": "off"
}
}
CI 流程跑 pnpm typecheck + pnpm lint,任何错误阻断 merge。这条我们抓得很严——有次手滑把一行未使用变量提交了,被 lint 拦下后养成了习惯。格式交给 Prettier 自动处理,团队成员不需要争论代码风格。
十二、Vue 文件类型增强
// shims.d.ts
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
现在用 vite + vue-tsc 的话,这一步其实可以省略,vite 的客户端类型已经能识别 .vue 模块。老项目迁移过来时保留这份 shims 文件主要是为了兼容历史配置。
十三、与 Pinia 集成
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface UserState {
userId: number | null
token: string
profile: UserProfile | null
}
export const useUserStore = defineStore('user', () => {
const userId = ref<UserState['userId']>(null) // 类型化
const token = ref<UserState['token']>('')
// ...
})
Pinia 用 setup 风格写 store 时,类型天然跟 Vue 的 ref/computed 对齐,state 里定义的形状直接用 UserState['xxx'] 引用,state 和类型定义不会漂移。
十四、踩过的坑
- 类型循环依赖:A 用 B 类型,B 用 A 类型。用
import type或抽公共类型。 - ref 自动解包陷阱:
arr[i]在模板是值,在 JS 是Ref<T> | undefined(开 noUncheckedIndexedAccess 后)。 - 类型扩展冲突:Vue Router 扩展 RouteMeta 多个文件重复
declare module报错。统一在一个router.d.ts里。 - 类型与运行时不一致:服务端返回
null但类型是{}。用 zod 在边界校验。 - vue-tsc 慢:项目大了类型检查 30s+。CI 用
vue-tsc --noEmit增量。 - vue-tsc 与 IDE 不一致:vue-tsc 是 ground truth,IDE 报红以它为准。
这些坑里,”类型与运行时不一致”是比较隐蔽的。前端类型默认服务端返回符合接口定义,但真实接口有时会返回 null 或者多出字段。我们的解法是在接口边界用 zod 做一次运行时校验,类型系统负责编译期,zod 负责运行时,两边各管一段。
十五、类型覆盖率指标
平台设了硬性目标:
any使用:< 1%- 公共函数有显式返回类型:100%
- 关键路径类型覆盖率:> 95%
每月报告 tsc --noEmit 通过率 + // @ts-expect-error 数量。
指标的意义在于把”类型安全”从口号变成可度量。数字会说话:any 的比例持续走低,说明团队在往更严格的类型方向收敛;@ts-expect-error 的数量异常上涨,往往意味着某块类型定义和真实数据结构脱节了,需要及时排查。
常见问题(FAQ)
Q1:必须 strict 模式吗?
建议开。strict 模式发现的 bug 是非 strict 的 3-5 倍。
Q2:第三方库没类型怎么办?
自己写 declare module 'xxx',或加 // @ts-ignore(不要用 any)。
Q3:类型定义文件放哪?
src/types/ 统一管理。组件 props 类型就近放 .vue 文件。