Validating Uploads
Two layers: multer limits while the bytes arrive, ParseFilePipe once the file is complete, and never trusting originalname.
new ParseFilePipe({ validators?: FileValidator[], errorHttpStatusCode?: ErrorHttpStatusCode, exceptionFactory?: (error: string) => any, fileIsRequired?: boolean })An upload is checked at two separate moments, and the difference is practical, not academic.
While the bytes arrive
multer's limits and fileFilter run inside FileInterceptor, during the interceptor step, as the request is still being read. Rejecting here ends the transfer early. limits.fileSize surfaces as a 413, while an unexpected field, too many files or too many parts surface as 400s.
Once the file is complete
ParseFilePipe is a pipe, so it runs after the interceptors and before the handler: middleware, guards, interceptors, pipes, handler. By then the file is already fully in memory or on disk, so a rejection here has cost you the whole upload. You pass it validators. MaxFileSizeValidator({ maxSize }) compares file.size in bytes. FileTypeValidator({ fileType }) takes a MIME string or a RegExp and, by default, determines the type from the file's magic numbers, the signature bytes at the start of the content, rather than the header the client sent. errorHttpStatusCode changes the failure status from its default of 400, and fileIsRequired: false permits no file at all.
The filename is user input
originalname and mimetype are strings the client chose. A name can contain .., a path separator, or a second extension such as cat.png.exe. Generate the stored name yourself and keep originalname only as a label to show back.
Shorthand and longhand
new ParseFilePipeBuilder().addMaxSizeValidator(...).build(...) returns exactly the ParseFilePipe you would otherwise construct by hand, with the validators in the order you added them. The two forms are the same object.
Example
// src/avatars/avatars.controller.ts
import { randomUUID } from 'node:crypto';
import {
Controller,
FileTypeValidator,
HttpStatus,
MaxFileSizeValidator,
ParseFilePipe,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
const MAX_BYTES = 2 * 1024 * 1024;
// We choose the extension from the type we detected, never from the name
// the client sent.
const EXTENSION: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
};
Controller('avatars')
export class AvatarsController {
Post()
// Layer 1: aborts the transfer as it happens, with a 413.
UseInterceptors(FileInterceptor('avatar', { limits: { fileSize: MAX_BYTES } }))
upload(
// Layer 2: runs after the file is fully received, before this method.
UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: MAX_BYTES }),
new FileTypeValidator({
fileType: /^image\/(jpeg|png|webp)$/,
// Replaces file.mimetype with the type detected from the
// content, so the lookup below cannot be spoofed.
overrideMimeType: true,
}),
],
errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, // default is 400
}),
)
file: Express.Multer.File,
) {
const storedName = `${randomUUID()}${EXTENSION[file.mimetype]}`;
return {
storedName,
// Keep the client's name for display only. It is never a path.
displayName: file.originalname,
};
}
}When to use it
- Accept only JPEG, PNG or WebP avatars up to 2 MiB.
- Reject an executable that was renamed to .png before it reaches your storage.
- Make the file optional on a PATCH that may update only text fields.
- Return 422 with a specific message instead of Nest's default 400 on a bad upload.
More examples
The builder and the pipe it builds
Both handlers behave identically: a 10 MiB PDF cap, 422 on failure, and no error when the field is absent. The builder also has addValidator() for your own FileValidator, so a long chain stays readable. Recognising both matters because the docs and most codebases mix them freely. fileIsRequired: false is what you want on a PATCH where the file is optional; leave it out and a request with no file is rejected.
// src/docs/docs.controller.ts
import {
Controller,
FileTypeValidator,
HttpStatus,
MaxFileSizeValidator,
ParseFilePipe,
ParseFilePipeBuilder,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
const MAX_BYTES = 10 * 1024 * 1024;
@Controller('docs')
export class DocsController {
// Longhand: construct the pipe and list its validators.
@Post('long')
@UseInterceptors(FileInterceptor('doc'))
long(
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: MAX_BYTES }),
new FileTypeValidator({ fileType: 'application/pdf' }),
],
errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY,
fileIsRequired: false,
}),
)
doc?: Express.Multer.File,
) {
return { received: Boolean(doc) };
}
// Shorthand: the builder produces the identical pipe. build() takes the
// same options as the constructor, minus validators.
@Post('short')
@UseInterceptors(FileInterceptor('doc'))
short(
@UploadedFile(
new ParseFilePipeBuilder()
.addMaxSizeValidator({ maxSize: MAX_BYTES })
.addFileTypeValidator({ fileType: 'application/pdf' })
.build({
errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY,
fileIsRequired: false,
}),
)
doc?: Express.Multer.File,
) {
return { received: Boolean(doc) };
}
}A custom file validator
FileValidator is an abstract class with exactly two members to implement, isValid and buildErrorMessage. Subclass it when a rule is not size or type: a minimum size, a required field name, a checksum the client also sent. Be honest about what this one checks, though. It reads file.mimetype, which is the client's claim, so for formats with signature bytes pair it with FileTypeValidator rather than replacing it.
// src/uploads/validators/allowed-types.validator.ts
import { FileValidator } from '@nestjs/common';
export type AllowedTypesOptions = { types: readonly string[] };
// The options object passed to the constructor is available as
// this.validationOptions, typed by the generic parameter.
export class AllowedTypesValidator extends FileValidator<AllowedTypesOptions> {
// isValid may return a boolean or a promise; ParseFilePipe awaits either.
isValid(file?: Express.Multer.File): boolean {
if (!file) {
return false;
}
// A zero-byte part is a common client bug and passes a size check.
return file.size > 0 && this.validationOptions.types.includes(file.mimetype);
}
// Called only when isValid returned false. The string becomes the
// message in the 400 (or whatever errorHttpStatusCode says).
buildErrorMessage(file?: Express.Multer.File): string {
const expected = this.validationOptions.types.join(', ');
return `Expected one of ${expected}, received ${file?.mimetype ?? 'nothing'}`;
}
}
// Used like any built-in validator:
// @UploadedFile(
// new ParseFilePipe({
// validators: [new AllowedTypesValidator({ types: ['text/csv'] })],
// }),
// )
// file: Express.Multer.FileRejecting a file before it is stored
fileFilter sees only the part headers, so it can judge the declared type and field name but not the content. Its value is timing: a wrong type is refused before the bytes are written to disk or buffered. Use it as a cheap first gate on a public route, then let ParseFilePipe do the real content check on whatever gets through. Note it has no access to Nest's dependency injection, so anything configurable has to be closed over or read from the request.
// src/photos/photos.controller.ts
import {
BadRequestException,
Controller,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
const ACCEPTED = new Set(['image/jpeg', 'image/png']);
@Controller('photos')
export class PhotosController {
@Post()
@UseInterceptors(
FileInterceptor('photo', {
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
// Called once per file part, before a single byte is stored.
fileFilter: (_req, file, callback) => {
if (!ACCEPTED.has(file.mimetype)) {
// callback(error, false) fails the whole request with this error.
// An HttpException passes straight through, so this is a 400.
return callback(new BadRequestException('Only JPEG and PNG'), false);
}
// callback(null, false) would skip this file and keep the request.
callback(null, true);
},
}),
)
upload(@UploadedFile() photo: Express.Multer.File) {
return { bytes: photo.size };
}
}
Discussion