trpc-typed-form-data について
@falcondev-oss/trpc-typed-form-data は、ファイル以外の項目を JSON のサイドカーに格納し、ファイルは multipart パートのまま送ることで、tRPC に型付き FormData アップロードを追加します。クライアントとサーバーでアップロードをスキーマ検証する tRPC アプリ向けで、React Native と共有するスキーマにも対応します。
主な機能
- 01ファイル以外の項目を FormData のサイドカーにシリアライズ
- 02アップロードファイルを multipart パートとして保持
- 03クライアントリンクとサーバーミドルウェアを提供
- 04スキーマで項目とファイルをまとめて検証
- 05ファイルバリデーターと ReactNativeFile に対応
活用シーン
- 01ファイルを含む tRPC mutation のペイロードを型チェック
- 02アップロード項目とファイルをまとめて検証
- 03サーバーと React Native クライアントでファイルスキーマを共有
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ falcondev-oss/ skills --skill trpc-typed-form-dataインストール後、Claude Code で/trpc-typed-form-dataを入力して読み込まれたか確認します。
同梱ファイルreference/react-native.md
詳細
@falcondev-oss/trpc-typed-form-data
tRPC では FormData 入力に型を付けられません。ファイルはマルチパートのパートとして送信する必要があり、それ以外はすべて型のない文字列となり、input は undefined になります。このパッケージは、ファイル以外のフィールドをシリアライズされたサイドカーとして保持することで、エンドツーエンドの型安全性を取り戻します。サイドカーは FormData の追加フィールド(~data)に格納する JSON blob で、ファイルは実際のマルチパートのパートとして扱われます。クライアント、サーバー、スキーマがそれぞれこの仕組みの一部を担います。
エントリーポイントは 4 つです。
| インポート元 | 提供されるもの |
|---|---|
.../client | createTypedFormData、typedFormDataLink、ReactNativeFile |
.../server | typedFormData、createTypedFormDataPlugin |
.../zod | zod ネイティブの file() バリデーター(z.object の中にネストします) |
.(ルート) | file(Standard Schema)、isFile、型 — フレームワークに依存しないサブセット |
file、isFile、型(FileValue、FileValidationOptions、TypedFormData)は /client と /server からも再エクスポートされるため、すでに使用している場所からインポートできます。
3 つの接続ポイント — すべて必須です
サイドカーを機能させるには、すべての要素を接続する必要があります。1 つでも欠けると、不足しているものを示すメッセージとともにバリデーションが失敗します。
1. クライアントリンク — サイドカーを FormData にシリアライズします。終端リンクの前に配置してください。
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. サーバーミドルウェア — サイドカーを FormData に戻してデシリアライズします。tRPC インスタンスからプラグインを作成し、アップロードを受け付けるプロシージャにそのミドルウェアを concat で追加します。
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. 入力スキーマラッパー — 入力スキーマを typedFormData() でラップします。サイドカーとファイルパートを読み取り、まとめてバリデーションします。
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 }
}),
})
呼び出し — createTypedFormData でペイロードを作成し、そのまま .mutate() に渡します。フィールドはスキーマに対して型チェックされます。
import { createTypedFormData } from '@falcondev-oss/trpc-typed-form-data/client'
await trpc.upload.mutate(
createTypedFormData({
userId: '123',
file: new File(['contents'], 'example.txt'),
}),
)
ファイルの配列にも対応しています。キーに File[] を渡すと、各ファイルはそのキーの下に追加され、getAll で読み戻されます。
ファイルのバリデーション — z.file() より file() を推奨します
スキーマを React Native/Expo クライアントと共有する場合は、このパッケージの file() を使用してください。z.file() の推論型は、DOM の File がない環境(React Native)では any に劣化し、tRPC のサーバーからクライアントへの境界で型チェックが気付かれないまま失われます。file() は、Web の File/Blob API(name、size、type、arrayBuffer、stream、slice、text)の構造的なサブセットである安定した FileValue を、どの環境でも推論します。
2 種類ありますが、オプションは同じです({ maxSize?, minSize?, mimeTypes? }。すべて省略可能で、バイト数の上限・下限は指定値を含みます)。
// 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) は、スキーマ外で個別にチェックする際に unknown を FileValue に絞り込む型ガードです。これらのスキーマから得た値(プロシージャの input、アップロードヘルパー)には、File ではなく FileValue を注釈してください。
注意点
- 3 つの接続ポイントすべてが必要です。 リンク、ミドルウェア、ラッパーのいずれかが欠けると、それぞれ異なるバリデーションエラーが発生します。メッセージを確認してください。不足している要素が示されます。
- トランスフォーマーを一致させてください。 ルーターでトランスフォーマー(例:superjson)を使用している場合は、
typedFormDataLink({ transformer })にも渡してください。サイドカーはリンクのトランスフォーマーでシリアライズされ、ルーターのトランスフォーマーでデシリアライズされます。一致しない場合、サーバーでBAD_REQUESTが発生します。 - 転送フィールドのカスタマイズ。 デフォルトでは、サイドカーは
~dataフィールドで送信されます。transferDataKeyで変更できます。その場合、typedFormDataLink({ transferDataKey })とcreateTypedFormDataPlugin(t, { transferDataKey })の両方に同じ値を設定してください。 FormDataはバッチ処理できません。 アップロード経路では、httpBatchLinkではなくhttpLinkで終端してください。- ミドルウェアが実行されるのは、通常の
inputがないミューテーションだけです(FormData経路)。クエリとサブスクリプションは変更されずに通過します。
React Native/Expo
画像/ドキュメントピッカーからアップロードするには ReactNativeFile が必要です。RN の FormData は uri があるパートのみを送信し、Blob のバイト列は読み取りません。Expo のアップロードを設定する前に reference/react-native.md をお読みください。