{"id":13455179,"url":"https://github.com/rileytomasek/zodix","last_synced_at":"2025-05-16T05:07:07.225Z","repository":{"id":61322973,"uuid":"549173515","full_name":"rileytomasek/zodix","owner":"rileytomasek","description":"Zod utilities for Remix loaders and actions.","archived":false,"fork":false,"pushed_at":"2024-06-04T14:44:12.000Z","size":146,"stargazers_count":379,"open_issues_count":9,"forks_count":16,"subscribers_count":4,"default_branch":"master","last_synced_at":"2025-05-09T18:20:06.642Z","etag":null,"topics":["form-validation","formdata","remix","remix-run","schema-validation","typescript","urlsearchparams","validation","zod"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/rileytomasek.png","metadata":{"files":{"readme":"readme.md","changelog":null,"contributing":null,"funding":null,"license":"license","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2022-10-10T19:40:14.000Z","updated_at":"2025-05-08T01:00:58.000Z","dependencies_parsed_at":"2024-10-22T22:55:54.259Z","dependency_job_id":null,"html_url":"https://github.com/rileytomasek/zodix","commit_stats":{"total_commits":21,"total_committers":7,"mean_commits":3.0,"dds":"0.38095238095238093","last_synced_commit":"515bd9cd2188a923e1422e6c8a2065d7588f7481"},"previous_names":[],"tags_count":10,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rileytomasek%2Fzodix","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rileytomasek%2Fzodix/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rileytomasek%2Fzodix/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rileytomasek%2Fzodix/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/rileytomasek","download_url":"https://codeload.github.com/rileytomasek/zodix/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":254471061,"owners_count":22076585,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["form-validation","formdata","remix","remix-run","schema-validation","typescript","urlsearchparams","validation","zod"],"created_at":"2024-07-31T08:01:02.130Z","updated_at":"2025-05-16T05:07:02.216Z","avatar_url":"https://github.com/rileytomasek.png","language":"TypeScript","funding_links":[],"categories":["typescript","TypeScript"],"sub_categories":[],"readme":"# Zodix\n\n[![Build Status](https://github.com/rileytomasek/zodix/actions/workflows/main.yml/badge.svg)](https://github.com/rileytomasek/zodix/actions/workflows/main.yml) [![npm version](https://img.shields.io/npm/v/zodix.svg?color=0c0)](https://www.npmjs.com/package/zodix)\n\nZodix is a collection of [Zod](https://github.com/colinhacks/zod) utilities for [Remix](https://github.com/remix-run/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.\n\nRemix loaders often look like:\n```ts\nexport async function loader({ params, request }: LoaderArgs) {\n  const { id } = params;\n  const url = new URL(request.url);\n  const count = url.searchParams.get('count') || '10';\n  if (typeof id !== 'string') {\n    throw new Error('id must be a string');\n  }\n  const countNumber = parseInt(count, 10);\n  if (isNaN(countNumber)) {\n    throw new Error('count must be a number');\n  }\n  // Fetch data with id and countNumber\n};\n```\n\nHere is the same loader with Zodix:\n```ts\nexport async function loader({ params, request }: LoaderArgs) {\n  const { id } = zx.parseParams(params, { id: z.string() });\n  const { count } = zx.parseQuery(request, { count: zx.NumAsString });\n  // Fetch data with id and countNumber\n};\n```\n\nCheck the [example app](/examples/app/routes) for complete examples of common patterns.\n\n## Highlights\n\n- Significantly reduce Remix action/loader bloat\n- Avoid the oddities of FormData and URLSearchParams\n- Tiny with no external dependencies ([Less than 1kb gzipped](https://bundlephobia.com/package/zodix))\n- Use existing Zod schemas, or write them on the fly\n- Custom Zod schemas for stringified numbers, booleans, and checkboxes\n- Throw errors meant for Remix CatchBoundary by default\n- Supports non-throwing parsing for custom validation/errors\n- Works with all Remix runtimes (Node, Deno, Vercel, Cloudflare, etc)\n- Full [unit test coverage](/src)\n\n## Setup\n\nInstall with npm, yarn, pnpm, etc.\n\n```sh\nnpm install zodix zod\n```\n\nImport the `zx` object, or specific functions:\n\n```ts\nimport { zx } from 'zodix';\n// import { parseParams, NumAsString } from 'zodix';\n```\n\n## Usage\n\n### zx.parseParams(params: Params, schema: Schema)\n\nParse and validate the `Params` object from `LoaderArgs['params']` or `ActionArgs['params']` using a Zod shape:\n\n```ts\nexport async function loader({ params }: LoaderArgs) {\n  const { userId, noteId } = zx.parseParams(params, {\n    userId: z.string(),\n    noteId: z.string(),\n  });\n};\n```\n\nThe same as above, but using an existing Zod object schema:\n\n```ts\n// This is if you have many pages that share the same params.\nexport const ParamsSchema = z.object({ userId: z.string(), noteId: z.string() });\n\nexport async function loader({ params }: LoaderArgs) {\n  const { userId, noteId } = zx.parseParams(params, ParamsSchema);\n};\n```\n\n### zx.parseForm(request: Request, schema: Schema)\n\nParse and validate `FormData` from a `Request` in a Remix action and avoid the tedious `FormData` dance:\n\n```ts\nexport async function action({ request }: ActionArgs) {\n  const { email, password, saveSession } = await zx.parseForm(request, {\n    email: z.string().email(),\n    password: z.string().min(6),\n    saveSession: zx.CheckboxAsString,\n  });\n};\n```\n\nIntegrate with existing Zod schemas and models/controllers:\n\n```ts\n// db.ts\nexport const CreateNoteSchema = z.object({\n  userId: z.string(),\n  title: z.string(),\n  category: NoteCategorySchema.optional(),\n});\n\nexport function createNote(note: z.infer\u003ctypeof CreateNoteSchema\u003e) {}\n```\n\n```ts\nimport { CreateNoteSchema, createNote } from './db';\n\nexport async function action({ request }: ActionArgs) {\n  const formData = await zx.parseForm(request, CreateNoteSchema);\n  createNote(formData); // No TypeScript errors here\n};\n```\n\n\n### zx.parseQuery(request: Request, schema: Schema)\n\nParse and validate the query string (search params) of a `Request`:\n\n```ts\nexport async function loader({ request }: LoaderArgs) {\n  const { count, page } = zx.parseQuery(request, {\n    // NumAsString parses a string number (\"5\") and returns a number (5)\n    count: zx.NumAsString,\n    page: zx.NumAsString,\n  });\n};\n```\n\n### zx.parseParamsSafe() / zx.parseFormSafe() / zx.parseQuerySafe()\n\nThese work the same as the non-safe versions, but don't throw when validation fails. They use [`z.parseSafe()`](https://github.com/colinhacks/zod#safeparse) and always return an object with the parsed data or an error.\n\n```ts\nexport async function action(args: ActionArgs) {\n  const results = await zx.parseFormSafe(args.request, {\n    email: z.string().email({ message: \"Invalid email\" }),\n    password: z.string().min(8, { message: \"Password must be at least 8 characters\" }),\n  });\n  return json({\n    success: results.success,\n    error: results.error,\n  });\n}\n```\n\nCheck the [login page example](/examples/app/routes/login.tsx) for a full example.\n\n## Error Handling\n\n### `parseParams()`, `parseForm()`, and `parseQuery()`\n\nThese functions throw a 400 Response when the parsing fails. This works nicely with [Remix catch boundaries](https://remix.run/docs/en/v1/guides/not-found#nested-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.\n\n```ts\nexport async function loader({ params }: LoaderArgs) {\n  const { postId } = zx.parseParams(\n    params,\n    { postId: zx.NumAsString },\n    { message: \"Invalid postId parameter\", status: 400 }\n  );\n  const post = await getPost(postId);\n  return { post };\n}\nexport function CatchBoundary() {\n  const caught = useCatch();\n  return \u003ch1\u003eCaught error: {caught.statusText}\u003c/h1\u003e;\n}\n```\n\nCheck the [post page example](/examples/app/routes/posts/$postId.tsx) for a full example.\n\n### `parseParamsSafe()`, `parseFormSafe()`, and `parseQuerySafe()`\n\nThese functions are great for form validation because they don't throw when parsing fails. They always return an object with this shape:\n\n```ts\n{ success: boolean; error?: ZodError; data?: \u003cparsed data\u003e; }\n```\n\nYou can then handle errors in the action and access them in the component using `useActionData()`. Check the [login page example](/examples/app/routes/login.tsx) for a full example.\n\n## Helper Zod Schemas\n\nBecause `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.\n\n### Available Helpers\n\n#### zx.BoolAsString\n- `\"true\"` → `true`\n- `\"false\"` → `false`\n- `\"notboolean\"` → throws `ZodError`\n\n#### zx.CheckboxAsString\n- `\"on\"` → `true`\n- `undefined` → `false`\n- `\"anythingbuton\"` → throws `ZodError`\n\n#### zx.IntAsString\n- `\"3\"` → `3`\n- `\"3.14\"` → throws `ZodError`\n- `\"notanumber\"` → throws `ZodError`\n\n#### zx.NumAsString\n- `\"3\"` → `3`\n- `\"3.14\"` → `3.14`\n- `\"notanumber\"` → throws `ZodError`\n\nSee [the tests](/src/schemas.test.ts) for more details.\n\n### Usage\n\n```ts\nconst Schema = z.object({\n  isAdmin: zx.BoolAsString,\n  agreedToTerms: zx.CheckboxAsString,\n  age: zx.IntAsString,\n  cost: zx.NumAsString,\n});\n\nconst parsed = Schema.parse({\n  isAdmin: 'true',\n  agreedToTerms: 'on',\n  age: '38',\n  cost: '10.99'\n});\n\n/*\nparsed = {\n  isAdmin: true,\n  agreedToTerms: true,\n  age: 38,\n  cost: 10.99\n}\n*/\n```\n\n## Extras\n\n### Custom `URLSearchParams` parsing\n\nYou may have URLs with query string that look like `?ids[]=1\u0026ids[]=2` or `?ids=1,2` that aren't handled as desired by the built in `URLSearchParams` parsing.\n\nYou can pass a custom function, or use a library like [query-string](https://github.com/sindresorhus/query-string) to parse them with Zodix.\n\n```ts\n// Create a custom parser function\ntype ParserFunction = (params: URLSearchParams) =\u003e Record\u003cstring, string | string[]\u003e;\nconst customParser: ParserFunction = () =\u003e { /* ... */ };\n\n// Parse non-standard search params\nconst search = new URLSearchParams(`?ids[]=id1\u0026ids[]=id2`);\nconst { ids } = zx.parseQuery(\n  request,\n  { ids: z.array(z.string()) }\n  { parser: customParser }\n);\n\n// ids = ['id1', 'id2']\n```\n\n### Actions with Multiple Intents\n\nZod discriminated unions are great for helping with actions that handle multiple intents like this:\n\n```ts\n// This adds type narrowing by the intent property\nconst Schema = z.discriminatedUnion('intent', [\n  z.object({ intent: z.literal('delete'), id: z.string() }),\n  z.object({ intent: z.literal('create'), name: z.string() }),\n]);\n\nexport async function action({ request }: ActionArgs) {\n  const data = await zx.parseForm(request, Schema);\n  switch (data.intent) {\n    case 'delete':\n      // data is now narrowed to { intent: 'delete', id: string }\n      return;\n    case 'create':\n      // data is now narrowed to { intent: 'create', name: string }\n      return;\n    default:\n      // data is now narrowed to never. This will error if a case is missing.\n      const _exhaustiveCheck: never = data;\n  }\n};\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frileytomasek%2Fzodix","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Frileytomasek%2Fzodix","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frileytomasek%2Fzodix/lists"}