前后端共享 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。迁移可以分三步,每步都能独立验证:

  1. 先共享出参:把前端 interface 移到 shared/types/,后端出参也改成引用它。此时前端仍然不需要改代码(import type 就行);
  2. 再共享入参:把后端的入参 DTO 移到 shared/types/,前端表单开始引用它,前端这时可以顺手补上校验体验;
  3. 最后收紧出参:在后端加 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),就要回答一个问题:谁先发。

安全顺序永远是「先发共享包,再发使用方」:

  1. 在共享包里加字段(可选字段,不删旧字段),发一个新版本;
  2. 等使用方升级到新版本并通过类型检查;
  3. 再删旧字段,发下一个版本。

反过来做——先删字段再发包——使用方在装到新版本之前就编译失败,整个团队被迫同时升级,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、任何框架相关代码
改字段怎么不炸前端? 优先加可选字段;必须改名就走「新旧并存」的过渡期

最后提醒一句常被忽略的事:共享层是接口契约,不是垃圾桶。往里塞越多东西,前后端的耦合就越紧;共享层越薄,编译期能替你兜住的风险就越多,类型却越好维护。

参考资料

标签

  • #TypeScript
  • #dto
  • #fullstack
  • #class-class-validator

相关课程