Fastify plugin for handling multipart/form-data with file validation, type coercion, and nested object support
npm install fastify-multipart-filemultipart/form-data requests with automatic type coercion, file validation, and nested object support.
items[0].name)
bash
npm install fastify-multipart-file
`
Note: This package includes @fastify/multipart as a dependency and registers it automatically. No additional setup needed!
Usage
$3
`typescript
import Fastify from "fastify";
import { register as FastifyRegisterMultipartFile } from "fastify-multipart-file";
const fastify = Fastify({
ajv: {
customOptions: {
strict: false,
removeAdditional: true,
useDefaults: true,
coerceTypes: true,
},
},
});
// Register the multipart handler (includes @fastify/multipart automatically)
await FastifyRegisterMultipartFile(fastify);
await fastify.listen({ port: 3000 });
`
$3
`typescript
import S from "fluent-json-schema";
fastify.post("/upload", {
schema: {
body: S.object()
.prop("name", S.string().required())
.prop("age", S.number())
.prop("isActive", S.boolean())
.prop(
'image',
S.object().raw({
type: 'string',
typeFile: true,
format: 'binary',
description: 'Image file (max size: 5MB)',
maxLength: 5 1024 1024,
accept: ["image/jpeg", "image/png", "image/gif"],
}),
),
},
handler: async (request, reply) => {
const { name, age, isActive, image } = request.body;
// 'name' is string
// 'age' is number (auto-converted)
// 'isActive' is boolean (auto-converted)
// 'image' is File object with buffer
console.log(image.buffer); // Buffer
console.log(image.mimetype); // e.g., 'image/jpeg'
console.log(image.size); // File size in bytes
console.log(image.originalName); // Original filename
return { success: true };
},
});
`
$3
`typescript
fastify.post("/nested", {
schema: {
body: S.object()
.prop("items[0].name", S.string())
.prop("items[0].quantity", S.number())
.prop("items[1].name", S.string())
.prop("items[1].quantity", S.number())
.prop("metadata.tags", S.array()),
},
handler: async (request, reply) => {
const { items, metadata } = request.body;
// items is: [
// { name: '...', quantity: 123 },
// { name: '...', quantity: 456 }
// ]
// metadata is: { tags: [...] }
return { success: true };
},
});
`
$3
The plugin supports multiple file uploads in the same request using different field names or array notation.
#### Option 1: Different Field Names
`typescript
fastify.post("/upload-multiple", {
schema: {
body: S.object()
.prop("name", S.string().required())
.prop(
'image',
S.object().raw({
type: 'string',
typeFile: true,
format: 'binary',
description: 'Image file (max size: 5MB)',
maxLength: 5 1024 1024,
accept: ["image/jpeg", "image/png"],
}),
)
.prop(
'document',
S.object().raw({
type: 'string',
typeFile: true,
format: 'binary',
description: 'Document file (max size: 10MB)',
maxLength: 10 1024 1024,
accept: ["application/pdf"],
}),
)
.prop(
'thumbnail',
S.object().raw({
type: 'string',
typeFile: true,
format: 'binary',
description: 'Image file (max size: 1MB)',
maxLength: 1 1024 1024,
accept: ["image/png", "image/gif"],
}),
),
},
handler: async (request, reply) => {
const { name, image, document, thumbnail } = request.body;
// Process multiple files
console.log("Image:", image.originalName, image.size);
console.log("Document:", document.originalName, document.size);
console.log("Thumbnail:", thumbnail.originalName, thumbnail.size);
// Save files to storage
await saveFile(image.buffer, image.name);
await saveFile(document.buffer, document.name);
await saveFile(thumbnail.buffer, thumbnail.name);
return {
success: true,
files: {
image: image.name,
document: document.name,
thumbnail: thumbnail.name,
},
};
},
});
`
#### Option 2: Array of Files
`typescript
fastify.post("/upload-array", {
schema: {
body: S.object()
.prop("title", S.string().required())
.prop(
'images[0]',
S.object().raw({
type: 'string',
typeFile: true,
format: 'binary',
description: 'Image file (max size: 5MB)',
maxLength: 5 1024 1024,
accept: ["image/jpeg", "image/png"],
}),
)
.prop(
'images[1]',
S.object().raw({
type: 'string',
typeFile: true,
format: 'binary',
description: 'Image file (max size: 5MB)',
maxLength: 5 1024 1024,
accept: ["image/jpeg", "image/png"],
}),
)
.prop(
'images[2]',
S.object().raw({
type: 'string',
typeFile: true,
format: 'binary',
description: 'Image file (max size: 5MB)',
maxLength: 5 1024 1024,
accept: ["image/jpeg", "image/png"],
}),
),
},
handler: async (request, reply) => {
const { title, images } = request.body;
// images is an array of File objects
console.log(Received ${images.length} images for: ${title});
const uploadedFiles = [];
for (const [index, image] of images.entries()) {
console.log(Image ${index}:, image.originalName, image.size);
await saveFile(image.buffer, image.name);
uploadedFiles.push(image.name);
}
return {
success: true,
title,
filesCount: images.length,
files: uploadedFiles,
};
},
});
`
#### Option 3: Mixed Files and Data
`typescript
fastify.post("/product", {
schema: {
body: S.object()
.prop("name", S.string().required())
.prop("price", S.number().required())
.prop("description", S.string())
.prop("inStock", S.boolean())
.prop(
'mainImage',
S.object().raw({
type: 'string',
typeFile: true,
format: 'binary',
description: 'Image file (max size: 5MB)',
maxLength: 5 1024 1024,
accept: ["image/jpeg", "image/png"],
}),
)
.prop(
'gallery[0]',
S.object().raw({
type: 'string',
typeFile: true,
format: 'binary',
description: 'Image file (max size: 3MB)',
maxLength: 3 1024 1024,
accept: ["image/jpeg", "image/png"],
}),
)
.prop(
'gallery[1]',
S.object().raw({
type: 'string',
typeFile: true,
format: 'binary',
description: 'Image file (max size: 3MB)',
maxLength: 3 1024 1024,
accept: ["image/jpeg", "image/png"],
}),
)
.prop(
'manual',
S.object().raw({
type: 'string',
typeFile: true,
format: 'binary',
description: 'Manual file (max size: 20MB)',
maxLength: 20 1024 1024,
accept: ["application/pdf"],
}),
),
},
handler: async (request, reply) => {
const { name, price, description, inStock, mainImage, gallery, manual } =
request.body;
// Save product data
const product = {
name, // string
price, // number (auto-converted)
description, // string
inStock, // boolean (auto-converted)
mainImageUrl: await uploadToS3(mainImage.buffer, mainImage.name),
galleryUrls: await Promise.all(
gallery.map((img) => uploadToS3(img.buffer, img.name))
),
manualUrl: await uploadToS3(manual.buffer, manual.name),
};
return { success: true, product };
},
});
`
How It Works
The plugin adds two Fastify hooks:
1. preValidation Hook: Processes multipart fields before validation
- Detects file uploads and validates them
- Converts string values to appropriate types based on schema
- Handles nested object notation
2. preHandler Hook: Reconstructs file buffers after validation
- Restores file objects with proper Buffer instances
- Merges file data back into the request body
Schema Properties
$3
`typescript
S.string()
.format("binary")
.maxLength(5 1024 1024) // Maximum file size in bytes
.raw({ accept: ["image/jpeg", "image/png"] }); // Allowed MIME types
`
$3
The plugin automatically converts values based on the schema type:
- S.number() → Converts to number
- S.integer() → Converts to integer
- S.boolean() → Converts to boolean ('true', '1' → true)
- S.object() → Parses JSON string to object
- S.array() → Parses JSON string to array
If no schema is provided, the plugin attempts to infer the type from the value.
API
$3
Main plugin registration function.
$3
`typescript
import {
// Types
FileUpload,
MultipartField,
SchemaProperty,
SchemaBody,
SerializedFile,
ProcessedFile,
ValidationError,
// Classes
File,
FileMapper,
UnprocessedEntityError,
// Helpers
JsonHelper,
UuidHelper,
// Type Guards
isValidationError,
} from "fastify-multipart-file";
`
$3
`typescript
class File {
name?: string; // Generated unique filename with extension
mimetype?: string; // MIME type (e.g., 'image/jpeg')
encoding?: string; // Encoding (e.g., '7bit')
buffer: Buffer; // File content as Buffer
size: number; // File size in bytes
originalName?: string; // Original filename from upload
}
`
$3
`typescript
class FileMapper {
static from(uploadFile: FileUpload): File;
}
`
Converts a raw file upload to a processed File object with a unique name.
$3
The plugin throws UnprocessedEntityError (HTTP 422) when:
- File size exceeds maxLength
- File MIME type is not in the accept list
The error follows a standard validation error format:
`typescript
import { isValidationError, ValidationError } from "fastify-multipart-file";
try {
// Handle multipart request
} catch (error) {
if (isValidationError(error)) {
console.log(error.statusCode); // 422
console.log(error.message); // 'Validation error'
console.log(error.validation); // Array of { field, message }
// Example output:
// [
// {
// field: 'avatar',
// message: 'File size exceeds the maximum allowed size of 5242880 bytes.'
// }
// ]
}
}
`
Error Response Format:
`json
{
"statusCode": 422,
"message": "Validation error",
"validation": [
{
"field": "avatar",
"message": "File size exceeds the maximum allowed size of 5242880 bytes."
}
]
}
`
Advanced Usage
$3
`typescript
import { FileMapper, File } from 'fastify-multipart-file';
fastify.post('/custom', async (request, reply) {
const { document } = request.body as { document: File };
// Access file properties
console.log(document.name); // UUID-based filename
console.log(document.originalName); // Original filename
console.log(document.buffer); // Buffer for processing
console.log(document.size); // Size in bytes
console.log(document.mimetype); // MIME type
// Save to disk, upload to S3, etc.
await saveToS3(document.buffer, document.name);
return { fileId: document.name };
});
`
$3
`typescript
import {
isValidFileField,
validateFileSize,
validateFileMimeType,
} from "fastify-multipart-file";
// Manually validate files if needed
const file = {
/ File object /
};
validateFileSize(file, 1024 * 1024, "avatar"); // Throws if > 1MB
validateFileMimeType(file, ["image/jpeg"], "avatar"); // Throws if not JPEG
``