Zodix

Zod utilities for Remix loaders and actions.

README

Zodix


Zodix is a collection of Zod utilities for Remix loaders and actions. It abstracts the complexity of parsing and validating FormDataand URLSearchParamsso your loaders/actions stay clean and are strongly typed.

Remix loaders often look like:

  1. ``` ts
  2. export async function loader({ params, request }: LoaderArgs) {
  3.   const { id } = params;
  4.   const url = new URL(request.url);
  5.   const count = url.searchParams.get('count') || '10';
  6.   if (typeof id !== 'string') {
  7.     throw new Error('id must be a string');
  8.   }
  9.   const countNumber = parseInt(count, 10);
  10.   if (isNaN(countNumber)) {
  11.     throw new Error('count must be a number');
  12.   }
  13.   // Fetch data with id and countNumber
  14. };
  15. ```

Here is the same loader with Zodix:

  1. ``` ts
  2. export async function loader({ params, request }: LoaderArgs) {
  3.   const { id } = zx.parseParams(params, { id: z.string() });
  4.   const { count } = zx.parseQuery(request, { count: zx.NumAsString });
  5.   // Fetch data with id and countNumber
  6. };
  7. ```

Check the example app for complete examples of common patterns.

Highlights


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
Throw errors meant for Remix CatchBoundary by default
Supports non-throwing parsing for custom validation/errors
Works with all Remix runtimes (Node, Deno, Vercel, Cloudflare, etc)
Full unit test coverage

Setup


Install with npm, yarn, pnpm, etc.

  1. ``` shell
  2. npm install zodix zod
  3. ```

Import the zxobject, or specific functions:

  1. ``` ts
  2. import { zx } from 'zodix';
  3. // import { parseParams, NumAsString } from 'zodix';
  4. ```

Usage


zx.parseParams(params: Params, schema: Schema)


Parse and validate the Paramsobject from LoaderArgs['params']or ActionArgs['params']using a Zod shape:

  1. ``` ts
  2. export async function loader({ params }: LoaderArgs) {
  3.   const { userId, noteId } = zx.parseParams(params, {
  4.     userId: z.string(),
  5.     noteId: z.string(),
  6.   });
  7. };
  8. ```

The same as above, but using an existing Zod object schema:

  1. ``` ts
  2. // This is if you have many pages that share the same params.
  3. export const ParamsSchema = z.object({ userId: z.string(), noteId: z.string() });

  4. export async function loader({ params }: LoaderArgs) {
  5.   const { userId, noteId } = zx.parseParams(params, ParamsSchema);
  6. };
  7. ```

zx.parseForm(request: Request, schema: Schema)


Parse and validate FormDatafrom a Requestin a Remix action and avoid the tedious FormDatadance:

  1. ``` ts
  2. export async function action({ request }: ActionArgs) {
  3.   const { email, password, saveSession } = await zx.parseForm(request, {
  4.     email: z.string().email(),
  5.     password: z.string().min(6),
  6.     saveSession: zx.CheckboxAsString,
  7.   });
  8. };
  9. ```

Integrate with existing Zod schemas and models/controllers:

  1. ``` ts
  2. // db.ts
  3. export const CreateNoteSchema = z.object({
  4.   userId: z.string(),
  5.   title: z.string(),
  6.   category: NoteCategorySchema.optional(),
  7. });

  8. export function createNote(note: z.infer<typeof CreateNoteSchema>) {}
  9. ```

  1. ``` ts
  2. import { CreateNoteSchema, createNote } from './db';

  3. export async function action({ request }: ActionArgs) {
  4.   const formData = await zx.parseForm(request, CreateNoteSchema);
  5.   createNote(formData); // No TypeScript errors here
  6. };
  7. ```

zx.parseQuery(request: Request, schema: Schema)


Parse and validate the query string (search params) of a Request:

  1. ``` ts
  2. export async function loader({ request }: LoaderArgs) {
  3.   const { count, page } = zx.parseQuery(request, {
  4.     // NumAsString parses a string number ("5") and returns a number (5)
  5.     count: zx.NumAsString,
  6.     page: zx.NumAsString,
  7.   });
  8. };
  9. ```

zx.parseParamsSafe() / zx.parseFormSafe() / zx.parseQuerySafe()


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.

  1. ``` ts
  2. export async function action(args: ActionArgs) {
  3.   const results = await zx.parseFormSafe(args.request, {
  4.     email: z.string().email({ message: "Invalid email" }),
  5.     password: z.string().min(8, { message: "Password must be at least 8 characters" }),
  6.   });
  7.   return json({
  8.     success: results.success,
  9.     error: results.error,
  10.   });
  11. }
  12. ```

Check the login page example for a full example.

Error Handling


parseParams(), parseForm(), and parseQuery()


These functions throw a 400 Response when the parsing fails. This works nicely with Remix catch 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.

  1. ``` ts
  2. export async function loader({ params }: LoaderArgs) {
  3.   const { postId } = zx.parseParams(
  4.     params,
  5.     { postId: zx.NumAsString },
  6.     { message: "Invalid postId parameter", status: 400 }
  7.   );
  8.   const post = await getPost(postId);
  9.   return { post };
  10. }
  11. export function CatchBoundary() {
  12.   const caught = useCatch();
  13.   return <h1>Caught error: {caught.statusText}</h1>;
  14. }
  15. ```

Check the post page example for a full example.

parseParamsSafe(), parseFormSafe(), and parseQuerySafe()


These functions are great for form validation because they don't throw when parsing fails. They always return an object with this shape:

  1. ``` ts
  2. { success: boolean; error?: ZodError; data?: <parsed data>; }
  3. ```

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.

Helper Zod Schemas


Because FormDataand URLSearchParamsserialize 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.

Available Helpers


zx.BoolAsString


"true"true
"false"false
"notboolean"→ throws ZodError

zx.CheckboxAsString


"on"true
undefinedfalse
"anythingbuton"→ throws ZodError

zx.IntAsString


"3"3
"3.14"→ throws ZodError
"notanumber"→ throws ZodError

zx.NumAsString


"3"3
"3.14"3.14
"notanumber"→ throws ZodError

See the tests for more details.

Usage


  1. ``` ts
  2. const Schema = z.object({
  3.   isAdmin: zx.BoolAsString,
  4.   agreedToTerms: zx.CheckboxAsString,
  5.   age: zx.IntAsString,
  6.   cost: zx.NumAsString,
  7. });

  8. const parsed = Schema.parse({
  9.   isAdmin: 'true',
  10.   agreedToTerms: 'on',
  11.   age: '38',
  12.   cost: '10.99'
  13. });

  14. /*
  15. parsed = {
  16.   isAdmin: true,
  17.   agreedToTerms: true,
  18.   age: 38,
  19.   cost: 10.99
  20. }
  21. */
  22. ```

Extras


Custom URLSearchParams parsing


You may have URLs with query string that look like ?ids[]=1&ids[]=2or ?ids=1,2that aren't handled as desired by the built in URLSearchParamsparsing.

You can pass a custom function, or use a library like query-string to parse them with Zodix.

  1. ``` ts
  2. // Create a custom parser function
  3. type ParserFunction = (params: URLSearchParams) => Record<string, string | string[]>;
  4. const customParser: ParserFunction = () => { /* ... */ };

  5. // Parse non-standard search params
  6. const search = new URLSearchParams(`?ids[]=id1&ids[]=id2`);
  7. const { ids } = zx.parseQuery(
  8.   request,
  9.   { ids: z.array(z.string()) }
  10.   { parser: customParser }
  11. );

  12. // ids = ['id1', 'id2']
  13. ```

Actions with Multiple Intents


Zod discriminated unions are great for helping with actions that handle multiple intents like this:

  1. ``` ts
  2. // This adds type narrowing by the intent property
  3. const Schema = z.discriminatedUnion('intent', [
  4.   z.object({ intent: z.literal('delete'), id: z.string() }),
  5.   z.object({ intent: z.literal('create'), name: z.string() }),
  6. ]);

  7. export async function action({ request }: ActionArgs) {
  8.   const data = await zx.parseForm(request, Schema);
  9.   switch (data.intent) {
  10.     case 'delete':
  11.       // data is now narrowed to { intent: 'delete', id: string }
  12.       return;
  13.     case 'create':
  14.       // data is now narrowed to { intent: 'create', name: string }
  15.       return;
  16.     default:
  17.       // data is now narrowed to never. This will error if a case is missing.
  18.       const _exhaustiveCheck: never = data;
  19.   }
  20. };
  21. ```