07 TypeScript 与 Express 工程整合

1. 类型安装

npm install multer
npm install --save-dev @types/multer

@types/multer 会扩展 Express Request:

req.file?: Express.Multer.File;
req.files?: Express.Multer.File[] | {
  [fieldname: string]: Express.Multer.File[];
};

它只是静态类型,运行时仍要检查文件是否存在、是哪种结构。

2. 单文件 Controller

import type {
  Request,
  Response,
} from "express";

export async function uploadAvatar(
  req: Request,
  res: Response,
): Promise<void> {
  const file = req.file;

  if (file === undefined) {
    res.status(400).json({
      code: "FILE_REQUIRED",
      message: "请选择头像",
    });
    return;
  }

  const result = await avatarService.process({
    temporaryPath: file.path,
    originalName: file.originalname,
    declaredMimeType: file.mimetype,
    size: file.size,
  });

  res.status(201).json({ data: result });
}

Service 接收明确 DTO,而不是整个 Request。

3. MemoryStorage DTO

interface InMemoryUploadInput {
  buffer: Buffer;
  originalName: string;
  declaredMimeType: string;
  size: number;
}
const input: InMemoryUploadInput = {
  buffer: file.buffer,
  originalName: file.originalname,
  declaredMimeType: file.mimetype,
  size: file.size,
};

只在配置确实使用 MemoryStorage 时访问 buffer。类型声明无法保证当前 storage engine 一定设置该属性,工程中应让 upload 配置与 Controller 成对组织。

4. .array() 类型缩小

export function requireFileArray(
  req: Request,
): Express.Multer.File[] {
  if (!Array.isArray(req.files)) {
    throw new Error("当前接口需要文件数组");
  }

  return req.files;
}

Controller:

const files = requireFileArray(req);

if (files.length === 0) {
  res.status(400).json({ message: "至少上传一个文件" });
  return;
}

5. .fields() 类型守卫

type FileMap = {
  [fieldname: string]: Express.Multer.File[];
};

function isFileMap(
  files: Request["files"],
): files is FileMap {
  return files !== undefined && !Array.isArray(files);
}
if (!isFileMap(req.files)) {
  res.status(400).json({ message: "文件字段不正确" });
  return;
}

const avatar = req.files.avatar?.[0];
const gallery = req.files.gallery ?? [];

还要验证:

6. 配置与路由放在一起

推荐:

routes/
  document.ts
  document.upload.ts
  document.validation.ts

controllers/
  document.controller.ts

services/
  document.service.ts

middlewares/
  upload-error.ts

document.upload.ts

import multer from "multer";

export const documentUpload = multer({
  storage: multer.diskStorage({
    destination: "var/uploads/incoming",
  }),
  limits: {
    fileSize: 10 * 1024 * 1024,
    files: 1,
    fields: 5,
    parts: 6,
  },
});

Multer 配置属于接口安全契约,不要在多个路由中复制不同版本的限制。

7. 认证应放在上传前

router.post(
  "/documents",
  requireAuthentication,
  requireUploadPermission,
  documentUpload.single("document"),
  validateDocumentFields,
  validateRequest,
  uploadDocument,
);

如果先上传再鉴权,未登录用户也能消耗带宽、磁盘、内存和扫描资源。

可以在进入 Multer 前完成的检查都应提前:

8. 配合 express-validator

multipart 文本字段需要先由 Multer 解析:

router.post(
  "/documents",
  requireAuthentication,
  documentUpload.single("document"),
  body("title")
    .isString()
    .bail()
    .trim()
    .isLength({ min: 1, max: 100 }),
  validateRequest,
  uploadDocument,
);

顺序:

Multer
  → req.body 获得 multipart 文本字段
  → express-validator
  → Controller

问题是:文本校验失败时文件已经可能落盘。需要错误处理中间件或上传 Service 清理 req.file

9. 在上传前校验哪些文本信息

multipart 内部字段顺序不可靠,因此无法用普通 body() 中间件在 Multer 前完整验证 multipart 文本字段。

如果某些信息必须在传输文件前决定权限或目标,可以放在:

例如先创建上传会话:

POST /upload-sessions
  → 校验业务参数
  → 返回 uploadSessionId

POST /upload-sessions/:id/file
  → 鉴权并上传文件

这比依赖 multipart 中晚到的 userId 更稳妥。

10. matchedData 与文件组合

interface DocumentFields {
  title: string;
  categoryId: number;
}

const fields = matchedData<DocumentFields>(req, {
  locations: ["body"],
});

const file = req.file;

if (file === undefined) {
  // 清晰处理。
}

await documentService.create({
  fields,
  file: {
    path: file.path,
    originalName: file.originalname,
    declaredMimeType: file.mimetype,
    size: file.size,
  },
});

不要把 req.body 整体传给 Sequelize,也不要把完整 Multer File 对象直接保存为 JSON。

11. 自定义错误类型

export class UploadValidationError extends Error {
  constructor(
    public readonly code: string,
    message: string,
    public readonly status = 400,
  ) {
    super(message);
    this.name = "UploadValidationError";
  }
}
throw new UploadValidationError(
  "FILE_SIGNATURE_MISMATCH",
  "文件内容与声明类型不一致",
  415,
);

应用错误处理中间件统一识别,不让 Controller 充满格式转换逻辑。

12. TypeScript 不会验证文件内容

const file: Express.Multer.File = req.file;

只表示对象符合类型声明,不代表:

类型系统不能代替运行时安全检查。

13. Supertest 测试

import request from "supertest";

await request(app)
  .post("/documents")
  .field("title", "测试文档")
  .field("categoryId", "1")
  .attach("document", testFilePath)
  .expect(201);

测试非法字段:

await request(app)
  .post("/documents")
  .attach("wrongField", testFilePath)
  .expect(400);

测试不能只检查状态码,还应检查临时目录没有残留文件。

14. 练习题

  1. 安装 @types/multer,观察 Request 增加的类型。
  2. .array().fields() 分别编写类型守卫。
  3. 把当前上传 Controller 改成只向 Service 传递 DTO。
  4. 在 Multer 后使用 express-validator 校验 title,并处理校验失败的文件清理。
  5. 设计 upload session,让业务参数先于文件上传完成校验。
  6. 使用 Supertest 测试正常、无文件、字段错误、文件过大和残留清理。

官方参考