FormData & Streaming
QuickModel provides first-class support for FormData, Blob, and File objects, as well as streaming for large binary fields. This guide covers all four layers:
- Layer 1 —
BlobTransformerandFileTransformer - Layer 2 — Auto-detection of file source format
- Layer 3 —
fromFormData()andtoFormData()API - Layer 4 —
toReadableStream(),fromStream(), andpipeStream()
Layer 1 — BlobTransformer & FileTransformer
Use the Blob or File constructor directly in @Quick to register the transformer:
import { QModel } from 'quickmodel';
interface IProfileDto {
name: string;
avatar: File;
thumbnail: Blob;
}
@Quick({ avatar: File, thumbnail: Blob })
class ProfileDto extends QModel<IProfileDto> {
declare name: string;
declare avatar: File;
declare thumbnail: Blob;
}You can also use the string aliases 'blob' and 'file':
@Quick({ avatar: 'file', thumbnail: 'blob' })
class ProfileDto extends QModel<IProfileDto> { ... }Serialization & deserialization
BlobTransformer round-trips Blob objects through a lightweight descriptor:
// Serialized form
{ size: 1024, type: 'image/png', _blobRef: true }
// Deserialization accepts:
// - { size, type, _blobRef } ← serialized descriptor
// - ArrayBuffer / Uint8Array ← programmatic creation
// - 'data:image/png;base64,...' ← data URIFileTransformer preserves File metadata across serialization:
// Serialized form
{ name: 'foto.jpg', size: 204800, type: 'image/jpeg', lastModified: 1709123456 }
// Deserialization accepts:
// - { name, size, type, lastModified } ← serialized descriptor
// - File instance ← pass-through
// - Blob instance ← wrapped as File (from streaming)
// - 'data:image/jpeg;base64,...' ← data URILayer 2 — Auto-detection
fromFormData() inspects each FormDataEntryValue at runtime and picks the right conversion strategy without any explicit configuration:
What is the value?
│
├── File instance → keep as File (real browser file input)
├── Blob instance → keep as Blob
├── ArrayBuffer / Uint8Array → auto-wrap in Blob (programmatic)
├── string "data:..." → decode base64 → Blob (legacy JS / APIs)
├── string "https://..." → keep as string URL (CDN reference)
├── string "/storage/..." → keep as string path (internal server reference)
└── string "foto.jpg" → keep as string (filename only)This covers 90% of real-world cases with zero configuration.
Layer 3 — fromFormData() & toFormData()
fromFormData(fd, opts?)
Static method. Converts a FormData into a model instance using coercionStrategy: 'loose' and auto-detection by default.
// Default: auto-detection
const dto = UploadDto.fromFormData(formData);
// Force all binary fields as path references (e.g. internal proxy)
const dto = UploadDto.fromFormData(formData, { fileSource: 'reference' });
// Per-field overrides — mixed strategies in the same form
const dto = UploadDto.fromFormData(formData, {
fields: {
avatar: 'binary', // real binary upload
document: 'reference', // server-side path
thumbnail: 'base64', // data URI
},
});fileSource values:
| Value | Behaviour |
|---|---|
'auto' (default) | Runtime inspection — decision tree above |
'binary' | Keep File/Blob as-is; wrap ArrayBuffer/Uint8Array into Blob |
'reference' | Treat string values as path/URL, do not deserialize as binary |
'base64' | Expect data: prefix and decode to Blob; error if absent |
toFormData(opts?)
Instance method. Builds a FormData from the model's fields.
// Default: preserve binary objects
const fd = await dto.$qToFormData();
// Proxy / logging — avoid sending binary data over the wire
const fd = await dto.$qToFormData({ fileMode: 'reference' });
// Legacy API expecting base64
const fd = await dto.$qToFormData({ fileMode: 'base64' });
// Per-field overrides
const fd = await dto.$qToFormData({
fields: { avatar: 'binary', signature: 'base64' },
});Conversion table by fileMode:
| Field runtime type | 'auto' / 'binary' | 'reference' | 'base64' |
|---|---|---|---|
File | .append(k, file) | .append(k, file.name) | .append(k, dataURI) |
Blob | .append(k, blob, 'file') | .append(k, '[Blob]') | .append(k, dataURI) |
ArrayBuffer | auto-wrap in Blob + append | .append(k, '[binary]') | .append(k, dataURI) |
Uint8Array | auto-wrap in Blob + append | .append(k, '[binary]') | .append(k, dataURI) |
string / number / boolean | .append(k, String(v)) — all modes |
spoofMethod — HTTP method tunneling
Some backends (Laravel, Symfony, Rails) only accept multipart/form-data with POST. To work with those APIs you can inject a _method field as the first entry in the FormData — the backend then reads it and routes the request as if it were PUT, PATCH, or DELETE.
// One-time call option
const fd = await dto.$qToFormData({ spoofMethod: 'PUT' });
// FormData: _method=PUT, name=Alice, avatar=<File>Three-level cascade (lowest → highest priority)
| Level | Where | Example |
|---|---|---|
| Global | QConfig.defaults | QConfig.set({ defaults: { spoofMethod: 'PUT' } }) |
| Decorator | @Quick({}, { spoofMethod }) | @Quick({}, { spoofMethod: 'PATCH' }) |
| Call option | toFormData({ spoofMethod }) | toFormData({ spoofMethod: 'DELETE' }) |
A higher-level value always wins. If spoofMethod is undefined at all levels, no _method field is added.
// 1. Global default — applies to every model unless overridden
QConfig.set({ defaults: { spoofMethod: 'PUT' } });
// 2. Decorator — override the global default for a specific model
@Quick({}, { spoofMethod: 'PATCH' })
class UploadDto extends QModel<IUploadDto> { ... }
// 3. Call option — highest priority, overrides everything
const fd = await dto.$qToFormData({ spoofMethod: 'DELETE' });
// → _method=DELETE (ignores global PUT and decorator PATCH)Type-safe values — IQSpoofMethod
The spoofMethod option is typed as IQSpoofMethod, which provides autocomplete for all standard HTTP methods (RFC 7231, WebDAV, DeltaV) plus a string & {} catch-all for custom methods:
import type { IQSpoofMethod } from 'quickmodel';
const methods: IQSpoofMethod[] = ['PUT', 'PATCH', 'DELETE', 'PURGE', 'SEARCH'];fileMode per field — @QType({ fileMode })
When a field always needs a specific serialization strategy regardless of the call site, declare it directly on the property with @QType. This avoids repeating the override in every toFormData() call.
class UploadDto extends QModel<IUploadDto> {
declare name: string;
// Always serialize as base64 — legacy API consumer
@QType(File, { fileMode: 'base64' })
declare signature: File;
// Always keep path reference — CDN-managed asset
@QType(File, { fileMode: 'reference' })
declare thumbnail: File;
}Priority order (lowest → highest):
| Level | Where | Applied to |
|---|---|---|
| Decorator | @QType({ fileMode }) | that field in every call |
| Call option | toFormData({ fileMode }) | all fields in this call |
| Per-field call option | toFormData({ fields: { k: mode } }) | that field in this call |
A field-level call option always wins. If neither call option is provided, the decorator default applies. If the decorator has no fileMode, the field falls back to 'auto'.
serialize({ fileMode }) — binary fields in plain JSON
serialize() also accepts fileMode so you can control how File/Blob fields are represented in plain-object / JSON output — useful for logging, caching, or transport over a non-multipart API.
// Default: File → { name, size, type, lastModified }
const plain = dto.$qSerialize();
// Reference only — no binary data in the JSON output
const plain = dto.$qSerialize({ fileMode: 'reference' });
// { avatar: 'foto.jpg', ... }
// Base64 — embed the binary inside the JSON
const plain = dto.$qSerialize({ fileMode: 'base64' });
// { avatar: 'data:image/jpeg;base64,/9j/...', ... }Global default via QConfig:
QConfig.set({ defaults: { fileMode: 'reference' } });
// every serialize() / toFormData() call now defaults to 'reference'
// unless overridden at decorator or call levelLayer 4 — Streaming
Loading 500 MB into an ArrayBuffer before sending blocks the thread and can cause OOM in high-concurrency servers. The Web Platform solution is ReadableStream<Uint8Array>.
toReadableStream(opts)
Emits bytes from a binary field as Uint8Array chunks. The file is never fully in memory.
const stream = dto.$qToReadableStream({
field: 'video',
chunkSize: 64 * 1024, // default: 256 KB
});
// Upload to S3 without loading the file into memory
await s3.putObject({
Bucket: 'uploads',
Key: 'video.mp4',
Body: stream,
ContentType: dto.video.type,
});
// Or return directly as an HTTP response
return new Response(stream, {
headers: { 'Content-Type': dto.video.type },
});With progress:
let emitted = 0;
const stream = dto.$qToReadableStream({
field: 'video',
chunkSize: 64 * 1024,
onChunk: (chunk, total) => {
emitted += chunk.byteLength;
socket.emit('upload-progress', Math.round((emitted / total) * 100));
},
});multipart: true — full form streaming
When uploading a complete form (text fields + binary files) to a backend that only accepts POST multipart/form-data, you can stream the entire message without materializing a FormData object in memory:
const stream = dto.$qToReadableStream({ multipart: true });
await fetch('/api/upload', {
method: 'POST',
body: stream,
headers: {
'Content-Type': `multipart/form-data; boundary=${stream.boundary}`,
},
});The stream exposes a boundary property (auto-generated as 32 random hex characters) that you must include in the Content-Type header. Pass a custom value if the receiver requires a specific token:
const stream = dto.$qToReadableStream({
multipart: true,
boundary: 'my-custom-boundary',
chunkSize: 64 * 1024, // per-chunk size for binary fields
});What gets emitted:
| Field type | Behaviour |
|---|---|
string / number / boolean | Emitted as a text/plain part |
File / Blob | Streamed lazily as a binary part with Content-Type |
File / Blob + @QType({ fileMode: 'reference' }) | Text part containing the filename only |
null / undefined | Field silently skipped |
The output is fully RFC 2046 compliant and can be parsed by any standard multipart/form-data parser, including the native Request.formData() in browsers and Bun:
// Round-trip verification
const req = new Request('https://api/upload', {
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=${stream.boundary}`,
},
body: stream,
});
const fd = await req.formData();
// fd.get('name') === dto.name ✅
// (fd.get('avatar') as File).arrayBuffer() === original bytes ✅fromStream(stream, opts)
Reconstructs a binary field by accumulating chunks from an incoming ReadableStream.
async function handleUpload(req: Request) {
const dto = await UploadDto.fromStream(req.body, {
field: 'video', // which model field to populate
maxBytes: 500 * 1024 * 1024, // safety limit: 500 MB
onProgress: (received, total) => {
console.log(`${received}/${total} bytes received`);
},
});
// dto.video → Blob with all accumulated chunks
await saveToStorage(dto.video);
}pipeStream(src, dst, opts?) — zero-memory mode
Connects the input stream directly to a writable stream without accumulation. The server acts as a pure byte conductor.
// Pipe directly from the request to S3 — zero bytes in server memory
await UploadDto.pipeStream(req.body, s3UploadStream, {
maxBytes: 500 * 1024 * 1024,
onProgress: (bytes) => socket.emit('progress', bytes),
});When to use which API
Does the file fit comfortably in memory? (< ~50 MB)
│
├── YES → fromFormData() / toFormData()
│ ├── I need the real binary → fileMode/fileSource: 'auto' (default)
│ ├── I only need the reference → fileMode/fileSource: 'reference'
│ └── Legacy API / JSON with base64 → fileMode/fileSource: 'base64'
│
└── NO → toReadableStream() / fromStream() / pipeStream()
├── Upload to S3/CDN → toReadableStream({ field }) → S3 body
├── Full form upload in streaming → toReadableStream({ multipart: true })
├── Receive large upload → fromStream(req.body, { field })
├── Zero-memory pipe → pipeStream(src, dst)
└── Real-time progress → onChunk / onProgress callbacksComplete Usage Scenarios
Scenario 1 — Frontend sends form with file → Backend receives it
// FRONTEND
const fd = new FormData(formEl); // avatar: File from <input type="file">
const dto = UploadDto.fromFormData(fd);
// dto.avatar → File { name: 'foto.jpg', size: 204800, type: 'image/jpeg' }
// dto.userId → 42 (number, auto-coerced from string '42')
const { valid, rules } = dto.$qValidationReport();
if (!valid) {
showErrors(rules);
return;
}
// Small file — direct upload
await fetch('/api/upload', {
method: 'POST',
body: await dto.$qToFormData(),
});
// Large file — streaming with progress
const stream = dto.$qToReadableStream({
field: 'avatar',
onChunk: (chunk, total) => updateProgressBar(chunk.byteLength, total),
});
await fetch('/api/upload', { method: 'POST', body: stream });// BACKEND — receive, validate and process
async function handleUpload(req: Request) {
const fd = await req.formData();
const dto = UploadDto.fromFormData(fd);
const { valid, rules } = dto.$qValidationReport();
if (!valid) return Response.json({ errors: rules }, { status: 422 });
// Upload to S3 without loading into memory — direct pipe
const uploadStream = s3.createUploadStream({
Bucket: 'uploads',
Key: dto.avatar.name,
});
await UploadDto.pipeStream(dto.avatar.stream(), uploadStream);
return Response.json({ url: cdnUrl });
}Scenario 2 — Internal microservice: references only, no binaries
// FormData { avatar: '/storage/users/42/foto.jpg', ... }
const dto = UploadDto.fromFormData(fd, { fileSource: 'reference' });
// dto.avatar → '/storage/users/42/foto.jpg' (string, zero bytes in memory)Scenario 3 — Round-trip with legacy base64 API
// Receive: "data:image/jpeg;base64,/9j/..."
const dto = UploadDto.fromFormData(fd, { fileSource: 'base64' });
// dto.avatar → Blob { type: 'image/jpeg' }
// Re-send in the same format
const outFd = await dto.$qToFormData({ fileMode: 'base64' });
// outFd.get('avatar') → 'data:image/jpeg;base64,/9j/...' — exact round-tripMCP Skill
The quickmodel_form_data skill guides you through the full FormData workflow — generating fromFormData() / toFormData() code, choosing the right fileMode/fileSource option, and adding streaming with progress callbacks.
/mcp quickmodel_form_data intent=fromFormData model_fields="avatar: File, userId: number, tags: string[]"