coji/zodix(新しいタブで開きます)
主要言語: TypeScript
Zod utilities for React Router v7 loaders and actions.
- star 数
- 9
- watcher 数
- 0
- fork 数
- 0
- issue 数
- 0
- 最終更新
- 2025/11/02
主要言語: TypeScript
Zod utilities for React Router v7 loaders and actions.
Zodix is a collection of Zod utilities for React Router v7 loaders and actions. It abstracts the complexity of parsing and validating FormData and URLSearchParams so your loaders/actions stay clean and are strongly typed.
✨ Now with full support for both Zod v3 and v4!
React Router loaders often look like:
export async function loader({ params, request }: Route.LoaderArgs) { const { id } = params const url = new URL(request.url) const count = url.searchParams.get('count') || '10' if (typeof id !== 'string') { throw new Error('id must be a string') } const countNumber = parseInt(count, 10) if (isNaN(countNumber)) { throw new Error('count must be a number') } // Fetch data with id and countNumber }
Here is the same loader with Zodix:
export async function loader({ params, request }: Route.LoaderArgs) { const { id } = zx.parseParams(params, { id: z.string() }) const { count } = zx.parseQuery(request, { count: zx.NumAsString }) // Fetch data with id and count }
Check the example app for complete examples of common patterns.
Install with npm, yarn, pnpm, etc.
npm install @coji/zodix zod
Zodix supports both Zod v3 and v4 through separate import paths:
@coji/zodix (requires zod@^3.25.0 or later)@coji/zodix/v4 (requires zod@^4.0.0)// For Zod v3 import { zx } from '@coji/zodix' // For Zod v4 import { zx } from '@coji/zodix/v4'
Upgrading from Zod v3 to v4? Follow these steps:
npm install zod@^4.0.0@coji/zodix to @coji/zodix/v4Import the zx object, or specific functions:
import { zx } from '@coji/zodix' // import { parseParams, NumAsString } from '@coji/zodix';
Parse and validate the Params object from Route.LoaderArgs['params'] or Route.ActionArgs['params'] using a Zod shape:
export async function loader({ params }: Route.LoaderArgs) { const { userId, noteId } = zx.parseParams(params, { userId: z.string(), noteId: z.string(), }) }
The same as above, but using an existing Zod object schema:
// This is if you have many pages that share the same params. export const ParamsSchema = z.object({ userId: z.string(), noteId: z.string() }) export async function loader({ params }: Route.LoaderArgs) { const { userId, noteId } = zx.parseParams(params, ParamsSchema) }
Parse and validate FormData from a Request in a React Router action and avoid the tedious FormData dance:
export async function action({ request }: Route.ActionArgs) { const { email, password, saveSession } = await zx.parseForm(request, { email: z.string().email(), password: z.string().min(6), saveSession: zx.CheckboxAsString, }) }
Integrate with existing Zod schemas and models/controllers:
// db.ts export const CreateNoteSchema = z.object({ userId: z.string(), title: z.string(), category: NoteCategorySchema.optional(), }) export function createNote(note: z.infer<typeof CreateNoteSchema>) {}
import { CreateNoteSchema, createNote } from './db' export async function action({ request }: Route.ActionArgs) { const formData = await zx.parseForm(request, CreateNoteSchema) createNote(formData) // No TypeScript errors here }
Parse and validate the query string (search params) of a Request:
export async function loader({ request }: Route.LoaderArgs) { const { count, page } = zx.parseQuery(request, { // NumAsString parses a string number ("5") and returns a number (5) count: zx.NumAsString, page: zx.NumAsString, }) }
These work the same as the non-safe versions, but don't throw when validation fails. They use z.parseSafe() and always return an object with the parsed data or an error.
export async function action(args: Route.ActionArgs) { const results = await zx.parseFormSafe(args.request, { email: z.string().email({ message: 'Invalid email' }), password: z .string() .min(8, { message: 'Password must be at least 8 characters' }), }) return { success: results.success, error: results.error, } }
Check the login page example for a full example.
These functions throw a 400 Response when the parsing fails. This works nicely with React Router error boundaries and should be used for parsing things that should rarely fail and don't require custom error handling. You can pass a custom error message or status code.
export async function loader({ params }: Route.LoaderArgs) { const { postId } = zx.parseParams( params, { postId: zx.NumAsString }, { message: "Invalid postId parameter", status: 400 } ); const post = await getPost(postId); return { post }; } export function ErrorBoundary() { const error = useRouteError(); return <h1>Error: {error.statusText}</h1>; }
Check the post page example for a full example.
These functions are great for form validation because they don't throw when parsing fails. They always return an object with this shape:
{ success: boolean; error?: ZodError; data?: <parsed data>; }
You can then handle errors in the action and access them in the component using useActionData(). Check the login page example for a full example.
Sometimes you need to build schemas dynamically based on runtime data (e.g., filter options from a database). Zodix provides excellent support for this pattern.
Use the InferParams type helper to get explicit types from your schemas:
import { zx, type InferParams } from '@coji/zodix' const mySchema = z.object({ q: z.string().optional(), page: zx.IntAsString.optional(), }) type Params = InferParams<typeof mySchema> // → { q: string | undefined, page: number | undefined }
The recommended approach for dynamic schemas is using z.object().extend():
export async function loader({ request }: Route.LoaderArgs) { // Fetch dynamic filter categories from database/API const filterCategories = await getAvailableFilterCategories() // → [{ id: 'category', ... }, { id: 'brand', ... }, ...] // 1. Define base schema with known fields const baseSchema = z.object({ q: z.string().optional(), page: zx.IntAsString.optional(), }) // 2. Build dynamic fields from runtime data const dynamicFields = Object.fromEntries( filterCategories.map((cat) => [cat.id, z.string().optional()]), ) // 3. Extend base schema with dynamic fields const fullSchema = baseSchema.extend(dynamicFields) // 4. Parse with full type inference (Zod v4) const parsed = zx.parseQuery(request, fullSchema) // ✨ parsed.q is string | undefined // ✨ parsed.page is number | undefined // ✨ parsed[category.id] is string | undefined }
Note: With Zod v3, type inference for dynamic fields may return any due to TypeScript limitations. For better type safety with v3, consider alternative approaches.
For more advanced use cases and alternative patterns (URLSearchParams, JSON Record, etc.), see the Dynamic Schemas Guide. This guide covers:
Check the dynamic filters example for a complete working implementation.
Because FormData and URLSearchParams serialize all values to strings, you often end up with things like "5", "on" and "true". The helper schemas handle parsing and validating strings representing other data types and are meant to be used with the parse functions.
"true" → true"false" → false"notboolean" → throws ZodError"on" → trueundefined → false"anythingbuton" → throws ZodError"3" → 3"3.14" → throws ZodError"notanumber" → throws ZodError"3" → 3"3.14" → 3.14"notanumber" → throws ZodErrorSee the tests for more details.
const Schema = z.object({ isAdmin: zx.BoolAsString, agreedToTerms: zx.CheckboxAsString, age: zx.IntAsString, cost: zx.NumAsString, }) const parsed = Schema.parse({ isAdmin: 'true', agreedToTerms: 'on', age: '38', cost: '10.99', }) /* parsed = { isAdmin: true, agreedToTerms: true, age: 38, cost: 10.99 } */
Zodix provides separate import paths for Zod v3 and v4 compatibility:
import { z } from 'zod' // v3.x import { zx } from '@coji/zodix' // Default path for v3 const schema = z.object({ name: z.string(), age: zx.IntAsString, }) export async function loader({ params }: Route.LoaderArgs) { const data = zx.parseParams(params, schema) // Works with Zod v3! }
import { z } from 'zod' // v4.x import { zx } from '@coji/zodix/v4' // Use v4 path const schema = z.object({ name: z.string(), age: zx.IntAsString, }) export async function loader({ params }: Route.LoaderArgs) { const data = zx.parseParams(params, schema) // Works with Zod v4! }
You may have URLs with query string that look like ?ids[]=1&ids[]=2 or ?ids=1,2 that aren't handled as desired by the built in URLSearchParams parsing.
You can pass a custom function, or use a library like query-string to parse them with Zodix.
// Create a custom parser function type ParserFunction = ( params: URLSearchParams, ) => Record<string, string | string[]> const customParser: ParserFunction = () => { /* ... */ } // Parse non-standard search params const search = new URLSearchParams(`?ids[]=id1&ids[]=id2`) const { ids } = zx.parseQuery( request, { ids: z.array(z.string()) }, { parser: customParser }, ) // ids = ['id1', 'id2']
Zod discriminated unions are great for helping with actions that handle multiple intents like this:
// This adds type narrowing by the intent property const Schema = z.discriminatedUnion('intent', [ z.object({ intent: z.literal('delete'), id: z.string() }), z.object({ intent: z.literal('create'), name: z.string() }), ]) export async function action({ request }: Route.ActionArgs) { const data = await zx.parseForm(request, Schema) switch (data.intent) { case 'delete': // data is now narrowed to { intent: 'delete', id: string } return { success: true } case 'create': // data is now narrowed to { intent: 'create', name: string } return { success: true } default: // data is now narrowed to never. This will error if a case is missing. const _exhaustiveCheck: never = data } }
This project is a fork of rileytomasek/zodix. Thanks to Riley Tomasek for creating the original Zodix library.