Recipe

Type-safe file uploads with Node streams — no packages

TypeFirst · AgileLabs Engineering · 10 min read

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

Prerequisites: this recipe assumes the contract pattern from End-to-end type-safe APIs and opaque types from the opaque types deep-dive. The base Api machinery it extends lives in Core/Data/Api.ts.

← All posts