Zodix is a collection of Zod utilities for Remix loaders and actions. It abstracts the complexity of parsing and validating FormData
and URLSearchParams
so your loaders/actions stay clean and are strongly typed.
Remix loaders often look like:
export async function loader({ params, request }: 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 }: LoaderArgs) {
const { id } = zx.parseParams(params, { id: z.string() });
const { count } = zx.parseQuery(request, { count: zx.NumAsString });
// Fetch data with id and countNumber
};
- Significantly reduce Remix action/loader bloat
- Avoid the oddities of FormData and URLSearchParams.
- Tiny with no external dependencies. Less than 1kb gzipped.
- Use existing Zod schemas, or write them on the fly.
- Custom Zod schemas for stringified numbers, booleans, and checkboxes.
- Full unit test coverage
npm install zodix zod
import { zx } from 'zodix';
// Or
import { parseParams, NumAsString } from 'zodix';
Parse and validate the Params
object from LoaderArgs['params']
or ActionArgs['params']
using a Zod shape:
export async function loader({ params }: 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 }: LoaderArgs) {
const { userId, noteId } = zx.parseParams(params, ParamsSchema);
};
Parse and validate FormData
from a Request
in a Remix action and avoid the tedious FormData
dance:
export async function action({ request }: 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 CreatNoteSchema = 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 }: 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 }: LoaderArgs) {
const { count, page } = zx.parseQuery(request, {
// NumAsString parses a string number ("5") and returns a number (5)
count: zx.NumAsString,
page: zx.NumAsString,
});
};
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"
→ throwsZodError
"on"
→true
undefined
→false
"anythingbuton"
→ throwsZodError
"3"
→3
"3.14"
→ throwsZodError
"notanumber"
→ throwsZodError
"3"
→3
"3.14"
→3.14
"notanumber"
→ throwsZodError
See 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
}
*/
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 }: ActionArgs) {
const data = await zx.parseForm(request, Schema);
switch (data.intent) {
case 'delete':
// data is now narrowed to { intent: 'delete', id: string }
return;
case 'create':
// data is now narrowed to { intent: 'create', name: string }
return;
default:
// data is now narrowed to never. This will error if a case is missing.
const _exhaustiveCheck: never = data;
}
};