前后端共享 DTO 类型:让编译期拦住字段不一致
一个 class 同时当运行时校验规则和编译期类型:后端用 class-validator 校验入参,前端 import type 只取形状,字段一改编译期就报错。本文讲清共享目录边界、路径别名配置与类型与校验的分离原则。
- TypeScript
- 操作
- 进阶
- 更新于 2026-10-31
全栈项目里有一类 bug,测试阶段发现不了、线上也不会报错,只表现为「页面上某个位置是空的」:后端把字段 sortOrder 改名成 sort,前端还在读旧名字,TypeScript 不报错(因为前端声明的是自己的 interface),运行时拿到 undefined,模板渲染成空白。这类问题在联调时靠人肉对齐字段,改一个字段要通知三个人。
根治办法只有一条:让前后端读同一份类型定义,把「字段对不齐」这件事从运行时提前到编译期。这篇讲清一个 class 如何同时承担运行时校验与编译期类型两种职责、共享代码该放在哪、别名怎么配,以及最关键的边界——类型和校验是两条线,不能互相越界。
一、问题的根源:两份类型定义,两套真相
先看没有共享 DTO 时的典型代码。
后端(NestJS):
// server/src/articles/dto/create-article.dto.ts
import { IsInt, IsString, Min } from 'class-validator';
export class CreateArticleDto {
@IsString()
title!: string;
@IsInt()
@Min(0)
sortOrder!: number;
}
前端(Nuxt):
// app/types/article.ts
export interface Article {
id: number;
title: string;
sortOrder: number; // 与后端重复维护的第二份真相
}
两处都叫 sortOrder,所以现在是一致的。但下次后端把 sortOrder 改成 sort:
- 后端编译通过,数据库和接口返回都变成
sort; - 前端编译也通过——因为
Article是前端自己声明的,编译器不知道后端改了什么; - 运行时
article.sortOrder是undefined,页面安静地少了一块内容。
共享 DTO 把两份真相变成一份:
// shared/types/article.dto.ts —— 前后端唯一的事实源
import { IsInt, IsString, Min } from 'class-validator';
export class ArticleDto {
@IsInt()
id!: number;
@IsString()
title!: string;
@Min(0)
sortOrder!: number;
}
现在后端改名,前端在同一份文件上编译,立刻报错。
二、一个 class 为什么能同时干两件事
TypeScript 的 class 在编译后有两种身份:
| 身份 | 编译后是什么 | 谁需要它 |
|---|---|---|
| 运行时值 | 一个真实的构造函数,带装饰器元数据 | 后端:拿装饰器规则做校验 |
| 编译期类型 | 一个类型标记,描述实例的形状 | 前后端:约束字段名与类型 |
关键在于装饰器把校验规则挂在类上。后端用 ValidationPipe 读取这些元数据执行校验;前端用 import type 只取类型,编译后类型信息被完全擦除,装饰器与 class-validator 都不会进入浏览器包。
后端:校验靠装饰器,不靠 if
// server/src/articles/articles.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { CreateArticleDto } from '#shared/types/article.dto';
@Controller('articles')
export class ArticlesController {
constructor(private readonly articlesService: ArticlesService) {}
@Post()
create(@Body() dto: CreateArticleDto) {
return this.articlesService.create(dto);
}
}
配套的全局管道(NestJS 启动时注册一次):
// server/src/main.ts
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // 剥掉 DTO 里没声明的字段
transform: true, // 按 DTO 的类型把入参转换过去
forbidNonWhitelisted: true, // 出现未声明字段直接 400
}),
);
await app.listen(3001);
}
这三个选项的差别值得单独说:
whitelist: true:请求里多带的字段会被悄悄丢掉;forbidNonWhitelisted: true:请求里多带的字段直接报错 400。安全上更严格,但要和前端约定好请求体结构,否则联调时容易莫名其妙 400;transform: true:按 DTO 上的类型转换入参,比如表单传来的"42"会变成数字42;需要隐式转换时再打开transformOptions: { enableImplicitConversion: true }。
前端:只引类型
// app/composables/useArticles.ts
import type { ArticleDto } from '#shared/types/article.dto';
export async function useArticles() {
const { data } = await useFetch<ArticleDto[]>('/api/articles');
return { articles: computed(() => data.value ?? []) };
}
注意这里的 import type:
import type { ArticleDto } from '#shared/types/article.dto'; // 类型只在编译期存在
import { ArticleDto } from '#shared/types/article.dto'; // 运行时会把 class 打进包
import type 修饰的导入,在 TypeScript 编译产物里完全不存在——没有 require、没有 import、没有任何字节进入浏览器包。普通 import 则会把 class 与它引用的 class-validator 一起打包,既拖慢首屏,又把后端校验规则暴露给了客户端。
三、class-validator 的常用装饰器
import {
IsArray, IsBoolean, IsDateString, IsInt, IsOptional, IsString, IsUUID,
Length, Matches, Max, Min, ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
export class CreateCommentDto {
@IsString()
@Length(1, 200) // 字符串长度
@Matches(/^[^\s].*$/s) // 正则约束
content!: string;
@IsInt()
@Min(0)
@Max(100)
score!: number;
@IsOptional() // 可选字段
@IsUUID()
parentId?: string;
@IsArray()
@ValidateNested({ each: true }) // 数组里的每一项也要校验
@Type(() => CreateCommentDto) // 告诉 class-transformer 元素类型
replies!: CreateCommentDto[];
}
几个容易踩的点:
@Type()必须配@ValidateNested({ each: true }):否则嵌套对象不会被真正校验,只是被当成一个普通对象;@IsOptional()的语义是「值为 undefined 或 null 就跳过校验」,不是「字段可以随便传」;空字符串仍然会过@IsString()但过不了@Length(1, 200);@Min/@Max只对数字生效,字符串长度用@Length;- 装饰器语法需要 TypeScript 开启
experimentalDecorators(NestJS 与 Nuxt 的默认配置都已经包含),纯tsc项目要手动加。
装饰器速查表
| 类别 | 装饰器 | 作用 |
|---|---|---|
| 基础类型 | @IsString() @IsInt() @IsNumber() @IsBoolean() @IsDateString() @IsUUID() |
值必须是该类型 |
| 数组 | @IsArray() @ArrayMinSize(n) @ArrayMaxSize(n) |
数组及其长度 |
| 可选 | @IsOptional() |
值为 undefined / null 时跳过其余校验 |
| 数字范围 | @Min(n) @Max(n) |
数值上下界 |
| 字符串 | @Length(min, max) @Matches(regex) @Contains(str) @IsEmail() @IsUrl() |
字符串内容 |
| 嵌套 | @ValidateNested() @ValidateNested({ each: true }) + @Type(() => X) |
校验对象或数组里的对象 |
| 枚举 | @IsEnum(SomeEnum) |
值必须是枚举成员之一 |
| 自定义 | @Validate(CustomConstraint) |
自己实现校验规则 |
前端怎么拿到这份类型
三种写法都能拿到编译期类型,区别在于「谁负责保证它对」:
// 1) 显式泛型:前端自己写死返回类型
const { data } = await useFetch<ArticleDto[]>('/api/articles');
// 2) 接口层推导:前端调用后端定义的接口时自动推导
const { data } = await useFetch('/api/articles'); // 推断出 ArticleDto[]
// 3) 边界收敛:把后端类型收敛成前端视图模型
const articles = computed(() => (data.value ?? []).map((a) => ({ ...a, summary: a.summary ?? '' })));
第 2 种最省心——前端调用的是后端 server/api 里的同一个接口,类型由后端路由定义推导出来,不存在「写错泛型」的可能。但它要求前后端同仓(或者共享一份接口定义);跨仓库时通常退回第 1 种。
共享 DTO 的价值在第 1 种:泛型参数写的是共享类型,后端改字段时这个泛型会跟着报错——这正是我们要的效果。
四、共享代码放在哪、别名怎么配
目录选择
| 场景 | 建议结构 | 说明 |
|---|---|---|
| 前后端同一个仓库 | 根目录 shared/types/ |
最简单,两边都能相对引用 |
| monorepo | packages/shared/ 独立包 |
用 workspace 依赖,版本可以独立管理 |
| Nuxt 项目 | 根目录 shared/types/ |
Nuxt 3.14+ 内置,#shared 别名直接可用 |
| NestJS 项目 | src/shared/ 或独立包 |
用 tsconfig paths 或 workspace 包 |
路径别名
Nuxt 侧几乎零配置。shared/types/ 与 shared/utils/ 里的文件会被自动导入,其它位置用内置的 #shared 别名:
import type { ArticleDto } from '#shared/types/article.dto';
NestJS 侧用 tsconfig 的 paths,并注意运行时解析器也要认识这个别名——NestJS 默认用 tsc 编译到 CommonJS,paths 只影响类型检查,运行时需要 tsconfig-paths 或 module-alias 之类的方案;用 webpack 构建或直接用 workspace 包会更省事:
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"#shared/*": ["src/shared/*"]
},
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strict": true,
"strictPropertyInitialization": true
}
}
emitDecoratorMetadata 值得强调:NestJS 靠它把构造函数参数的类型元数据发射出来,容器才知道该注入哪个 Provider。少了它,注入会在运行时才报错。
共享层的硬边界
共享目录里只能放:
- DTO、枚举、常量、纯函数;
- 不依赖任何框架运行时的类型定义。
不能放:
- NestJS 的 Controller、Service、装饰器(
@Injectable()属于后端语义); - Vue 的响应式 API、组件、Nuxt 组合式函数(它们需要 Nuxt 上下文,在服务端不存在);
- Node.js 专属 API(会在浏览器包里炸掉);
- 任何带副作用的代码。
Nuxt 的 shared/ 目录把这条规定得更死:共享代码同时进入 Vue 应用包和 Nitro 服务端包,因此不能 import 任何 Vue 或 Nitro 的代码。
字段可选性要两端对齐
共享 DTO 里最容易出分歧的是「哪些字段可以缺省」:
export class ArticleDto {
@IsString()
title!: string;
@IsOptional()
@IsString()
summary?: string; // 前端必须处理 undefined
}
后端可以不传 summary,前端就必须写 article.summary ?? ''。如果前端把它声明成必填,类型系统不会报错——因为 ? 已经被带过去了,但人可能没注意。所以共享 DTO 的可选字段要在评审时明确一下。
五、monorepo 布局:一个能跑起来的最小工程
前面讲的是「放在哪、怎么引」,这一节给一个可以直接照抄的目录结构与配置。以「同一个仓库里前后端各一个目录」的最简形态为例:
my-app/
├── shared/ # 唯一事实源,前后端都引用它
│ └── types/
│ └── article.dto.ts # DTO、枚举、常量、纯函数
├── server/ # 后端(NestJS)
│ ├── package.json
│ └── tsconfig.json
└── app/ # 前端(Nuxt)
├── package.json
└── nuxt.config.ts
共享目录里的文件
// shared/types/article.dto.ts
import { IsInt, IsString, Min } from 'class-validator';
export class ArticleDto {
@IsInt()
id!: number;
@IsString()
title!: string;
@Min(0)
sortOrder!: number;
}
export enum ArticleStatus {
Draft = 'draft',
Published = 'published',
}
后端侧:tsconfig 别名 + 运行时解析
// server/tsconfig.json
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2022",
"experimentalDecorators": true, // 装饰器语法
"emitDecoratorMetadata": true, // 容器靠它解析构造参数类型
"strict": true,
"strictPropertyInitialization": true,
"baseUrl": ".",
"paths": {
"#shared/*": ["../shared/*"]
},
"outDir": "./dist"
},
"include": ["src/**/*.ts", "../shared/**/*.ts"]
}
这里有个必须注意的坑:paths 只影响 TypeScript 的类型检查,运行时不会自动生效。编译产物里仍然是 #shared/types/article.dto 这个字面量,Node.js 解析不了以 # 开头的模块说明符。三种常见解法:
| 方案 | 做法 | 适用 |
|---|---|---|
tsconfig-paths |
启动时用 node -r tsconfig-paths/register dist/main.js |
本地与简单部署 |
module-alias |
在 package.json 里配 _moduleAliases |
需要更可控的别名 |
| 直接用相对路径 | import ... from '../../shared/types/article.dto' |
目录层级不深时最省事 |
如果项目用 webpack 打包,或者把 shared 抽成独立的 npm 包(monorepo 的 packages/shared),这些问题就不存在了——这也是共享代码量变大之后最常见的迁移方向。
前端侧:Nuxt 的内置别名
Nuxt 3.14 起有 shared/ 目录,shared/types/ 与 shared/utils/ 自动导入,其余位置用 #shared:
// app/composables/useArticles.ts —— 前端只引类型
import type { ArticleDto } from '#shared/types/article.dto';
export function useArticles() {
return useFetch<ArticleDto[]>('/api/articles');
}
当共享代码多起来:抽成独立包
my-monorepo/
├── packages/
│ └── shared/
│ ├── package.json
│ ├── tsconfig.json
│ └── src/
│ ├── index.ts
│ └── types/
│ └── article.dto.ts
├── apps/
│ ├── server/
│ └── web/
// packages/shared/package.json
{
"name": "@my/shared",
"version": "0.1.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
}
用 npm workspaces 或 pnpm workspace 把 packages/shared 声明成前后端的共同依赖:
// apps/server/package.json
{
"dependencies": {
"@my/shared": "workspace:*"
}
}
独立包的好处是边界由包管理器强制——shared 里 import 了后端的东西,TypeScript 的 paths 未必拦得住,但包边界的 lint 规则(import/no-restricted-paths)可以;坏处是多一层构建配置。
六、类型与校验是两条线
最后强调本文最重要的一条边界:
| 后端 | 前端 | |
|---|---|---|
| 需要什么 | 运行时校验规则 | 编译期类型形状 |
| 怎么拿 | import { ArticleDto } |
import type { ArticleDto } |
| 改字段会怎样 | 校验规则跟着改,请求体约束自动更新 | 编译报错,改到能编译为止 |
| 能不能执行校验 | 能,这是它的本职 | 不需要,也不该 |
校验规则是后端语义。前端可以有自己的表单校验(那是体验优化),但不能依赖它来保证数据正确——请求可以绕过任何前端代码直接打接口。真正的防线永远是后端的 DTO 校验。
反过来,前端也不该执行后端的校验规则:把 @IsString() 之类的东西跑在前端,既重复了逻辑,又把校验代码塞进了浏览器包。
七、什么该共享、什么不该共享
共享 DTO 不是越多越好。下面这张表划出边界:
| 内容 | 该放共享层 | 原因 |
|---|---|---|
| 接口的入参形状与校验规则 | ✅ | 前后端都要按这个形状说话 |
| 接口的出参形状 | ✅ | 前端要按它渲染,后端要按它返回 |
| 枚举、状态常量、错误码 | ✅ | 两边必须用同一套取值 |
| 纯函数(格式化、校验辅助) | ✅ | 无依赖,天然可共享 |
| 数据库实体 / ORM 模型 | ❌ | 带 ORM 装饰器与表结构,属于后端内部 |
| 带业务计算的 Service | ❌ | 业务逻辑只应有一份实现 |
| 只在页面里用的视图模型 | ❌ | 后端不需要知道前端怎么拼卡片 |
判断标准很简单:这个定义是不是「接口契约」的一部分? 是,就共享;不是,就留在它该在的层。
出参类型要单独定义
入参和出参经常不一样——入参没有 id,出参有;入参有 password,出参绝不能有。所以不要拿同一个 class 兼任两者:
// 入参:客户端能传的字段
export class CreateArticleDto {
@IsString()
@Length(1, 120)
title!: string;
@IsOptional()
@IsString()
summary?: string;
}
// 出参:服务端返回的字段,比入参多
export class ArticleDto extends CreateArticleDto {
@IsInt()
id!: number;
@IsISO8601()
createdAt!: string;
}
继承在这里很好用:出参复用入参的字段定义,同时补上服务端生成的字段。关键是别把 password 这类敏感字段放进出参——一旦前端引了这个 class,就等于把字段名写进了浏览器包里。
迁移路径:从 interface 到 class
已有项目里前端通常是手写的 interface,后端是 class。迁移可以分三步,每步都能独立验证:
- 先共享出参:把前端 interface 移到
shared/types/,后端出参也改成引用它。此时前端仍然不需要改代码(import type就行); - 再共享入参:把后端的入参 DTO 移到
shared/types/,前端表单开始引用它,前端这时可以顺手补上校验体验; - 最后收紧出参:在后端加
ClassSerializerInterceptor之类的机制,确保返回的字段就是出参声明的那些,避免「DTO 里没声明的字段被顺手带出去」。
八、完整闭环示例
把上面串起来,一个从共享 DTO 到前后端使用的最小闭环:
// 1) shared/types/article.dto.ts —— 唯一事实源
import { IsInt, IsString, Min } from 'class-validator';
export class ArticleDto {
@IsInt()
id!: number;
@IsString()
title!: string;
@Min(0)
sortOrder!: number;
}
export class CreateArticleDto {
@IsString()
title!: string;
@Min(0)
sortOrder!: number;
}
// 2) 后端:Service 用 DTO 的类型做返回契约
// server/src/articles/articles.service.ts
import { Injectable } from '@nestjs/common';
import type { ArticleDto, CreateArticleDto } from '#shared/types/article.dto';
@Injectable()
export class ArticlesService {
create(dto: CreateArticleDto): ArticleDto {
return { id: 1, ...dto }; // 返回类型由共享 DTO 保证
}
}
<!-- 3) 前端:只引类型,字段名由编译器保证 -->
<!-- app/pages/articles/index.vue -->
<script setup lang="ts">
import type { ArticleDto } from '#shared/types/article.dto';
const { data } = await useFetch<ArticleDto[]>('/api/articles');
</script>
<template>
<ul>
<li v-for="a in data" :key="a.id">
{{ a.title }} — 第 {{ a.sortOrder }} 位
</li>
</ul>
</template>
后端把 sortOrder 改名的那一刻,前端这一行会直接报错:
Property 'sortOrder' does not exist on type 'ArticleDto'.
联调时互相猜字段,变成了编译器提前对齐。
九、字段演进:共享 DTO 怎么改才不炸前端
共享 DTO 一旦被多处引用,改字段就成了有风险的动作。下面四种改法,风险差别很大:
| 改法 | 前端会不会编译报错 | 建议 |
|---|---|---|
| 加一个可选字段 | 不会(前端不用处理) | 最安全,直接加 |
| 加一个必填字段 | 不会,但前端可能渲染出 undefined | 后端要保证一定有值;前端补上默认值 |
| 改字段名 | 会(属性不存在) | 走过渡期,见下 |
| 删字段 | 会 | 同上 |
改字段名的过渡写法
把旧字段保留为可选、新字段作为可选引入,过一个版本再删旧字段:
// 第一步:两个字段并存,旧字段标为已废弃
export class ArticleDto {
@IsInt()
id!: number;
/** @deprecated 请改用 sortOrder,这个字段将在下个版本移除 */
@IsOptional()
@IsInt()
sort?: number;
@IsOptional()
@IsInt()
sortOrder?: number;
}
后端同时返回两个字段:
return { id, sortOrder: row.sort, sort: row.sort }; // 过渡期同时给
等前端全部迁移到新字段、再删掉旧字段与后端的重复输出。过渡期可以长,但必须明确写下「什么时候删」,否则临时兼容会变成永久字段。
用类型层面强制迁移
如果团队愿意更严格一点,可以用工具类型让旧字段在编译期报错:
// 旧字段仍在 DTO 上,但对外类型里标记为已移除
export type ArticleResponse = Omit<ArticleDto, 'sort'> & { sortOrder: number };
这样前端引用旧字段会直接报 Property 'sort' does not exist on type 'ArticleResponse'——比「字段悄悄变 undefined」强得多。
接口文档也可以从 DTO 生成
共享 DTO 还有一个附带收益:接口文档可以由类型生成,而不是手写。NestJS 生态里 @nestjs/swagger 的 CLI 插件能扫描 Controller 的方法签名与 DTO 装饰器,自动生成 OpenAPI 文档;前端则可以用同一份 OpenAPI 生成请求客户端。这样「文档说的」和「代码做的」就是同一份定义,不再是两份真相。
代价是要在 DTO 上补 Swagger 装饰器(@ApiProperty),而这些装饰器属于后端语义——这时更应该把 Swagger 装饰器留在后端的 DTO 副本里,共享层只保留纯类型:
shared/types/article.dto.ts # 纯类型与 class-validator 规则,前后端共用
server/src/articles/dto/article.dto.ts # 后端 DTO:继承/组合共享类型 + Swagger 装饰器
这是「类型共享、校验共享、文档各写各的」一种务实折中。
十、把类型共享接进 CI
类型共享真正的价值不在于「写起来方便」,而在于不一致能在合并前被拦下来。如果只有编辑器提示、没有流水线检查,迟早有人绕过类型检查提交代码——用 as any、用 // @ts-ignore,或者干脆在页面里另写一份 interface。
一条命令同时检查两端
monorepo 里最省事的做法,是让一次类型检查同时覆盖后端与前端:
// package.json
{
"scripts": {
"typecheck": "npm run typecheck --workspaces --if-present",
"typecheck:server": "tsc -p server/tsconfig.json --noEmit",
"typecheck:web": "vue-tsc -p web/tsconfig.json --noEmit"
}
}
tsc --noEmit 只做类型检查、不产出文件,可以安全地在 CI 里跑;vue-tsc 是 tsc 的 Vue 感知版本,会检查 .vue 文件里 <script setup lang="ts"> 的类型。前端如果引了共享 DTO 而字段对不上,这一步就会失败。
用编译期断言守住「接口契约」
类型检查只能保证「前端用的是这个 DTO」,保证不了「后端真的返回了这些字段」。可以在共享层里写一组类型层面的契约断言,让违约在编译期就炸掉:
// shared/types/article.contract.ts
import type { ArticleDto } from './article.dto';
import type { ArticleResponse } from './article.response';
// 后端出参必须覆盖的字段:少一个,下面这行就编译不过
type ArticleContract = Required<Pick<ArticleDto, 'id' | 'title' | 'sortOrder'>>;
export const _articleContract: ArticleContract = {} as ArticleResponse;
这段代码不产生任何运行时开销——_articleContract 会被 TypeScript 擦除,import type 也不会留下 import。它的作用是把「后端出参应该有哪些字段」这条规则写进类型里:谁改了 DTO 却没同步另一端,tsc 立刻失败。
要让它真正起作用,ArticleResponse 必须是后端出参的真实类型,而不是另写一份 interface。共享层里入参和出参分成两个文件、两个类,是让断言有意义的前提——用同一个类同时当入参和出参,这条断言就永远成立,等于没写。
共享层要不要单独发版
共享代码跟主仓放在一起时,跟着主仓一起发布即可,CI 跑一次类型检查就够了。一旦共享层被拆成独立包(packages/shared),就要回答一个问题:谁先发。
安全顺序永远是「先发共享包,再发使用方」:
- 在共享包里加字段(可选字段,不删旧字段),发一个新版本;
- 等使用方升级到新版本并通过类型检查;
- 再删旧字段,发下一个版本。
反过来做——先删字段再发包——使用方在装到新版本之前就编译失败,整个团队被迫同时升级,CI 全红。共享层的版本号要按「有没有删字段 / 改字段类型」来定,而不是按功能数量:纯新增算小版本,删字段或改字段类型算破坏性变更。
十一、排错清单
| 现象 | 原因 | 修法 |
|---|---|---|
| 前端打包后体积暴涨 | 前端用了普通 import 引 DTO |
改成 import type |
| 后端启动报注入找不到 | tsconfig 少了 emitDecoratorMetadata |
补上该选项 |
| 嵌套对象没被校验 | 少了 @ValidateNested / @Type |
两个装饰器成对使用 |
| 编译报 strictPropertyInitialization | class 字段构造后才赋值 | 用确定性赋值断言 id!: number |
| 前端 import 路径编译不过 | 别名没配到各自的工具链 | Nuxt 用 #shared;NestJS 侧配 paths + 运行时解析 |
| 生产构建报找不到模块 | 只配了类型路径,运行时没解析 | 用 workspace 包或加 tsconfig-paths / module-alias |
| 请求体多传字段没报错 | whitelist 而不是 forbidNonWhitelisted |
按需要切换 |
| 共享目录在 Nuxt 构建时报错 | 共享代码 import 了 Vue 或 Nitro 的东西 | 只保留纯逻辑与类型 |
前端读到的字段是 undefined,但 TS 不报错 |
出参把可选字段写成了必填 | 出参类型要与后端实际返回一致,可选就标 ? |
| 后端返回了 DTO 里没有的字段 | Service 直接返回了数据库实体 | 用出参 DTO 显式挑选字段,或加序列化拦截器 |
请求体里传了 id 也被接受 |
出参与入参混用同一个 class | 入参单独定义,不含 id 等服务端字段 |
@IsOptional() 没拦住空字符串 |
它的语义是「undefined/null 才跳过」 | 字符串非空用 @Length(1, n) 或 @Matches |
| 数组里的对象没被校验 | 少了 @ValidateNested({ each: true }) + @Type |
两个装饰器成对使用 |
| 前端 dev 构建正常、生产构建报错 | dev 下宽松、生产开了 isolatedModules 等严格项 |
按生产配置本地跑一次类型检查 |
| 改了共享 DTO 后前端没报错 | 前端实际引的是自己的 interface | 把前端那份删掉,改为引用共享层 |
可选字段的两种处理
后端字段可选时,前端要么用默认值兜住,要么在类型上明确它可能不存在:
// 方案一:类型上就是可选,调用方必须处理 undefined
export class ArticleDto {
@IsOptional()
@IsString()
summary?: string;
}
// 方案二:前端在边界处收敛成确定类型,不让 undefined 扩散到组件里
const articles = computed(() =>
(data.value ?? []).map((a) => ({ ...a, summary: a.summary ?? '' })),
);
第二种更省心:把「可能没有」这件事在数据入口处一次性处理掉,组件里就不用到处写 ?? ''。代价是多一次映射,但换来组件 props 的类型干净。
小结
把这篇的要点收成一张单页速查:
| 问题 | 答案 |
|---|---|
| 为什么共享 DTO? | 把「字段对不齐」从运行时提前到编译期 |
| 一个 class 怎么兼顾两边? | class 既是运行时值(装饰器带校验规则)也是编译期类型 |
| 后端怎么用? | import { X } + 全局 ValidationPipe 读装饰器做校验 |
| 前端怎么用? | import type { X } —— 只在编译期存在,不进浏览器包 |
| 放在哪? | 根目录 shared/types/、Nuxt 的 shared/、或 monorepo 的 packages/shared |
| 别名怎么配? | Nuxt 用 #shared;NestJS 用 tsconfig paths + 运行时解析(tsconfig-paths / module-alias / workspace 包) |
| 什么不能放? | 数据库实体、Service、任何框架相关代码 |
| 改字段怎么不炸前端? | 优先加可选字段;必须改名就走「新旧并存」的过渡期 |
最后提醒一句常被忽略的事:共享层是接口契约,不是垃圾桶。往里塞越多东西,前后端的耦合就越紧;共享层越薄,编译期能替你兜住的风险就越多,类型却越好维护。
参考资料
- class-validator —— 校验装饰器清单与用法
- NestJS 官方文档:ValidationPipe —— 全局管道配置与嵌套校验
- TypeScript 手册:模块 ——
import type的编译期擦除语义 - TypeScript 手册:Utility Types ——
Required/Pick等内置类型工具 - vue-tsc(Vue 语言工具) —— 在 CI 里检查
.vue文件的类型 - Nuxt shared/ 目录 —— 共享层的导入范围与边界
- NestJS 依赖注入与模块边界 —— 容器如何管理实例与依赖
- Nuxt 4 渲染模式实战 —— 共享类型在前端的消费方式