mcpField
T
trpc-typed-form-data

trpc-typed-form-data

github.com/falcondev-ossDevelopment279 installsUpdated 4d ago
Repository

About trpc-typed-form-data

@falcondev-oss/trpc-typed-form-data adds typed FormData uploads to tRPC by serializing non-file fields into a JSON sidecar while keeping files as multipart parts. It is for tRPC applications that need schema-validated uploads across client and server, including shared schemas for React Native.

Key features

  1. 01Serializes non-file fields into a FormData sidecar
  2. 02Keeps uploaded files as multipart parts
  3. 03Provides client link and server middleware
  4. 04Validates combined fields and files with schemas
  5. 05Includes file validators and ReactNativeFile support

Use cases

  1. 01Type-check tRPC mutation payloads that include files
  2. 02Validate upload fields and files together
  3. 03Share file schemas across server and React Native clients

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/falcondev-oss/skills --skill trpc-typed-form-data

After installing, type/trpc-typed-form-datain Claude Code to confirm it loaded.

Bundled filesreference/react-native.md

Details

From the project README

@falcondev-oss/trpc-typed-form-data

tRPC can't type a FormData input: files must ride as multipart parts, everything else is untyped strings, and input arrives undefined. This package restores end-to-end type safety by carrying the non-file fields as a serialized sidecar — a JSON blob in one extra FormData field (~data) — while files stay as real multipart parts. Client, server, and schema each handle one half of that trick.

Four entry points:

Import fromGives you
.../clientcreateTypedFormData, typedFormDataLink, ReactNativeFile
.../servertypedFormData, createTypedFormDataPlugin
.../zodzod-native file() validator (nest in z.object)
. (root)file (Standard Schema), isFile, types — the framework-agnostic subset

file, isFile, and the types (FileValue, FileValidationOptions, TypedFormData) are re-exported from /client and /server too, so import them from wherever you already are.

Three touch points — all required

The sidecar only works if every piece is wired. Miss one and validation throws a message naming what's missing.

1. Client link — serializes the sidecar into the FormData. Place it before the terminating link:

import { typedFormDataLink } from '@falcondev-oss/trpc-typed-form-data/client'
import { createTRPCClient, httpLink } from '@trpc/client'

const trpc = createTRPCClient<AppRouter>({
  links: [
    typedFormDataLink(), // 👈 before httpLink
    httpLink({ url: '/api/trpc' }),
  ],
})

2. Server middleware — deserializes the sidecar back onto the FormData. Build the plugin from your tRPC instance and concat its middleware onto the procedures that accept uploads:

import { createTypedFormDataPlugin } from '@falcondev-oss/trpc-typed-form-data/server'

const t = initTRPC.create()
const typedFormDataPlugin = createTypedFormDataPlugin(t)

export const uploadProcedure = t.procedure.concat(typedFormDataPlugin.middleware)

3. Input schema wrapper — wrap the input schema in typedFormData(). It reads the sidecar + the file parts and validates them together:

import { typedFormData } from '@falcondev-oss/trpc-typed-form-data/server'
import { z } from 'zod'

export const router = t.router({
  upload: uploadProcedure
    .input(
      typedFormData(
        z.object({
          userId: z.string(),
          file: z.instanceof(File),
        }),
      ),
    )
    .mutation(({ input }) => {
      // input is fully typed: { userId: string; file: File }
    }),
})

Calling it — build the payload with createTypedFormData and pass it straight to .mutate(); the fields are type-checked against the schema:

import { createTypedFormData } from '@falcondev-oss/trpc-typed-form-data/client'

await trpc.upload.mutate(
  createTypedFormData({
    userId: '123',
    file: new File(['contents'], 'example.txt'),
  }),
)

Arrays of files work: pass File[] under a key — each file is appended under that key and read back with getAll.

File validation — prefer file() over z.file()

Reach for this package's file() when the schema is shared with a React Native / Expo client. z.file()'s inferred type degrades to any in environments without a DOM File (React Native), silently dropping type-checking across the tRPC server→client boundary. file() infers a stable FileValue — a structural subset of the Web File/Blob API (name, size, type, arrayBuffer, stream, slice, text) — everywhere.

Two flavours, same options ({ maxSize?, minSize?, mimeTypes? }, all optional, byte limits inclusive):

// zod stack — nests inside z.object like z.file()
import { file } from '@falcondev-oss/trpc-typed-form-data/zod'
typedFormData(z.object({
  avatar: file({ maxSize: 10_000_000, mimeTypes: ['image/png', 'image/jpeg'] }),
}))

// non-zod / any Standard Schema stack
import { file } from '@falcondev-oss/trpc-typed-form-data/server'

isFile(value) is a type guard narrowing unknown → FileValue for ad-hoc checks outside a schema. Annotate values that came out of these schemas (a procedure input, an upload helper) with FileValue, not File.

Gotchas

  • All three touch points, or nothing. Missing the link, the middleware, or the wrapper each produces a distinct validation error — read the message, it names the missing piece.
  • Transformer must match. If your router uses a transformer (e.g. superjson), pass it to typedFormDataLink({ transformer }) — the sidecar is serialized with the link's transformer and deserialized with the router's. A mismatch throws BAD_REQUEST on the server.
  • Custom transfer field. The sidecar rides in a ~data field by default. Override with transferDataKey — set the same value on both typedFormDataLink({ transferDataKey }) and createTypedFormDataPlugin(t, { transferDataKey }).
  • FormData isn't batchable. Terminate with httpLink, not httpBatchLink, on the upload path.
  • Middleware only fires for mutations with no plain input (the FormData path). Queries and subscriptions pass through untouched.

React Native / Expo

Uploading from an image/document picker needs ReactNativeFile — RN's FormData only sends parts with a uri and never reads a Blob's bytes. Read reference/react-native.md before wiring an Expo upload.

Comments

Markdown supported