Recipe
Type-safe file uploads with Node streams — no packages
The standard answer to file uploads in Express is "install multer." You get multipart parsing, but also an untyped req.file stapled onto the request, buffering behavior you have to configure defensively, and a dependency in the most security-sensitive endpoint you own. This recipe takes the TypeFirst route instead: no upload package at all, a contract that types the metadata, and Node's own streams for the bytes.
The trick: skip multipart entirely
Multipart encoding exists so an HTML form can mix fields and files in one request. If you're calling from JavaScript — which in ts-bedrock you always are — you don't need it. Send the file as the raw request body and the metadata as typed URL params:
PUT /upload/avatar?filename=:filename
Content-Type: application/octet-stream
<raw bytes> No boundary parsing, no field extraction — the body is the file. Everything that needs validation (the name, the size, the kind of file) travels where decoders can reach it.
Step 1 — The contract
The T3 Api type assumes a JSON body, so an upload gets its own small contract type. Same philosophy, one new field — the body is a stream, so what's left to declare is its limit:
// Core/Data/UploadApi.ts
export type UploadApi<
Route extends string,
UrlParams extends UrlRecord<Route>,
ErrorCode extends string,
Payload,
> = {
method: "PUT"
route: Route
urlDecoder: JD.Decoder<UrlParams>
responseDecoder: (status: HttpStatus) => JD.Decoder<ResponseJson<ErrorCode, Payload>>
maxBytes: number
} A concrete avatar-upload contract then reads like any other — including an opaque type for the filename, so a path-traversal string like "../../etc/cron" can't even exist past the boundary:
// Core/Api/Auth/UploadAvatar.ts
export type UrlParams = { filename: FileName } // opaque: [a-z0-9._-] only
export type ErrorCode = "FILE_TOO_LARGE" | "INVALID_FILE_TYPE"
export type Payload = { url: Url }
export const contract: Contract = {
method: "PUT",
route: "/upload/avatar?filename=:filename",
urlDecoder: JD.object({ filename: fileNameDecoder }),
responseDecoder: responseDecoder(errorCodeDecoder, payloadDecoder),
maxBytes: 5 * 1024 * 1024, // 5 MB
} Step 2 — The server: a size-limited pipeline
An Express req is a Node Readable stream. (One caveat: express.json() only consumes application/json bodies, so an octet-stream body arrives untouched.) The handler signature follows the usual pure-handler pattern, with the stream as an extra argument:
export type UploadHandler<P, E, T> = (
authUser: UserRow,
params: P,
body: Readable,
) => Promise<Result<E, T>> The whole streaming core is Node standard library. A Transform counts bytes and kills the pipe the moment the contract's limit is crossed — the file never finishes hitting disk, and memory usage stays flat no matter how large the upload:
import { Transform } from "node:stream"
import { pipeline } from "node:stream/promises"
import { createWriteStream } from "node:fs"
function byteLimit(maxBytes: number): Transform {
let seen = 0
return new Transform({
transform(chunk: Buffer, _enc, callback): void {
seen += chunk.length
return seen > maxBytes
? callback(new Error("BYTE_LIMIT"))
: callback(null, chunk)
},
})
}
export async function handler(
authUser: UserRow,
params: API.UrlParams,
body: Readable,
): Promise<Result<API.ErrorCode, API.Payload>> {
const path = avatarPath(authUser.id, params.filename)
try {
await pipeline(body, byteLimit(contract.maxBytes), createWriteStream(path))
} catch (e) {
await removePartialFile(path)
return err("FILE_TOO_LARGE")
}
return ok({ url: avatarUrl(authUser.id, params.filename) })
} Note the shape: the try/catch exists only at the stream boundary (streams throw; that's their nature), and it's immediately converted into a typed Result — the never-throw rule holds for everything above this line. Want to verify the file really is a PNG? Inspect the first chunk's magic bytes inside the same pipeline and fail with "INVALID_FILE_TYPE" — same pattern, one more transform.
Step 3 — The client: fetch already streams
No FormData needed. A browser File is a Blob, and fetch streams a Blob body without reading it into memory:
// Web/src/Api/Auth/UploadAvatar.ts
export async function call(
file: File,
params: UrlParams,
): Promise<Response> {
return uploadApi(contract, params, file)
// → fetch(toUrl(contract.route, params), {
// method: "PUT",
// headers: authHeader(),
// body: file,
// }) — response decoded by contract.responseDecoder
} The response comes back through the contract's decoder like every other endpoint, so the Web side gets the same exhaustive error handling: a switch over "FILE_TOO_LARGE" | "INVALID_FILE_TYPE" that fails to compile if the contract grows a new error. In the UI, the upload state is a RemoteData like any other async operation — and a progress bar is one more Transform-style step on the client, reading file.stream() and emitting a progress action per chunk.
What you didn't ship
- No multer, busboy or formidable — the attack surface of the most abused endpoint type is the Node standard library.
- No buffering — memory stays flat at any file size, and oversized uploads die mid-flight instead of after consuming bandwidth.
- No untyped
req.file— the filename is an opaque type that can't contain a path traversal, and the size limit lives in the contract where both sides can read it.
Api machinery it extends lives in Core/Data/Api.ts.