zod -> valibot (#3364)

This commit is contained in:
Kalle
2026-08-21 17:36:48 +03:00
committed by GitHub
parent a21e43c6b2
commit 4f64b9ec01
246 changed files with 4173 additions and 3455 deletions

View File

@@ -88,7 +88,7 @@ You should aim to colocate code that "changes together" as much as possible. Fea
- **FeatureRepository.server.ts**: Database queries & mappers (see `repositories.md`)
- **feature-constants.ts**: Constant values
- **feature-hooks.ts**: React hooks
- **feature-schemas.ts**: Zod schemas for validating form values, params, payloads
- **feature-schemas.ts**: valibot schemas for validating form values, params, payloads
- **feature-types.ts**: Typescript types
- **feature-utils.ts**: Utilities too small to make up for their own modules
- **Component.module.css**: CSS module matching the React file of the same root name
@@ -170,7 +170,7 @@ TODO (after React server actions in use)
### Forms
Forms are defined as Zod schemas built from the field builders in `~/form/fields` and rendered by `SendouForm`. The same schema validates the submission on the server. See `forms.md` for the full documentation.
Forms are defined as valibot schemas built from the field builders in `~/form/fields` and rendered by `SendouForm`. The same schema validates the submission on the server. See `forms.md` for the full documentation.
### Performance

View File

@@ -16,7 +16,7 @@ SQLite has no boolean type, so booleans are `0`/`1` integers typed as `DBBoolean
Converting to one:
- `toDBBoolean(someBoolean)` from `~/utils/sql` — use this instead of `Number(x)` or `x ? 1 : 0` when writing to the DB.
- `dbBoolean` / `checkboxValueToDbBoolean` from `~/utils/zod` for form and payload schemas.
- `dbBoolean` / `checkboxValueToDbBoolean` from `~/utils/schema` for form and payload schemas.
Reading is just truthiness (`if (build.isPrivate)`); convert to a real boolean with `Boolean()` when the value crosses into a domain type.

View File

@@ -1,10 +1,10 @@
# SendouForm - Schema-Based Form System
This document describes the schema-based form system using `SendouForm`. Forms are defined as Zod schemas that generate both the UI and server-side validation.
This document describes the schema-based form system using `SendouForm`. Forms are defined as valibot schemas that generate both the UI and server-side validation.
## Core Concepts
- Forms are defined as Zod schemas using field builders from `~/form/fields`
- Forms are defined as valibot schemas using field builders from `~/form/fields`
- The same schema validates both client-side and server-side
- All translations go in `locales/en/forms.json`
- `FormField` renders the correct UI based on schema metadata
@@ -14,7 +14,7 @@ This document describes the schema-based form system using `SendouForm`. Forms a
### Basic Schema Example
```ts
export const myFormSchema = z.object({
export const myFormSchema = v.object({
name: textField({
label: "labels.name",
maxLength: 100,
@@ -92,7 +92,7 @@ items: [
Define action discriminators with `stringConstant`:
```ts
export const myFormSchema = z.object({
export const myFormSchema = v.object({
_action: stringConstant("CREATE_ITEM"),
name: textField({ label: "labels.name", maxLength: 100 }),
});
@@ -103,7 +103,7 @@ export const myFormSchema = z.object({
Use `idConstant` for IDs that need default values:
```ts
export const editFormSchema = z.object({
export const editFormSchema = v.object({
itemId: idConstant(), // Requires defaultValues
name: textField({ label: "labels.name", maxLength: 100 }),
});
@@ -175,12 +175,12 @@ dualSelectOptional({
### Arrays and Fieldsets
```ts
const itemSchema = z.object({
const itemSchema = v.object({
name: textField({ label: "labels.itemName", maxLength: 50 }),
quantity: numberFieldOptional({ label: "labels.quantity" }),
});
export const formSchema = z.object({
export const formSchema = v.object({
items: array({
label: "labels.items",
min: 1,
@@ -192,7 +192,7 @@ export const formSchema = z.object({
### Union for Shared Field Definitions
Place field inside `z.union([])` to reuse across multiple schemas:
Place field inside `v.union([])` to reuse across multiple schemas:
```ts
const sharedNameField = textField({
@@ -200,18 +200,18 @@ const sharedNameField = textField({
maxLength: 100,
});
const createSchema = z.object({
const createSchema = v.object({
_action: stringConstant("CREATE"),
name: sharedNameField,
});
const editSchema = z.object({
const editSchema = v.object({
_action: stringConstant("EDIT"),
id: idConstant(),
name: sharedNameField,
});
export const actionSchema = z.union([createSchema, editSchema]);
export const actionSchema = v.union([createSchema, editSchema]);
```
## Component Usage
@@ -341,7 +341,7 @@ flow, while keeping `SendouForm`'s single-submit `application/json` model unchan
```ts
import { image } from "~/form/fields";
export const editTeamSchema = z.object({
export const editTeamSchema = v.object({
teamId: idConstant(),
logo: image({ label: "labels.logo" }), // logo (default)
banner: image({ label: "labels.banner", dimensions: "thick-banner" }),
@@ -411,15 +411,15 @@ Use `customField` for complex UI that doesn't fit standard field types:
### Schema
```ts
const povSchema = z.union([
z.object({ type: z.literal("USER"), userId: id.optional() }),
z.object({ type: z.literal("NAME"), name: z.string().max(100) }),
const povSchema = v.union([
v.object({ type: v.literal("USER"), userId: v.optional(id) }),
v.object({ type: v.literal("NAME"), name: v.pipe(v.string(), v.maxLength(100)) }),
]);
export const formSchema = z.object({
export const formSchema = v.object({
pov: customField(
{ initialValue: { type: "USER" as const } },
povSchema.optional()
v.optional(povSchema)
),
});
```
@@ -506,7 +506,7 @@ When you need async validation (database checks, authorization), create a separa
**Base schema (`feature-schemas.ts`)** - used by both client and server:
```ts
import { z } from "zod";
import * as v from "valibot";
import { textField, idConstantOptional } from "~/form/fields";
// Shared sync validation that can be extracted for reuse
@@ -524,45 +524,65 @@ function validateGearAllOrNone(data: {
// Export refine config for reuse in server schema
export const gearAllOrNoneRefine = {
fn: validateGearAllOrNone,
opts: { message: "forms:errors.gearAllOrNone", path: ["head"] },
message: "forms:errors.gearAllOrNone",
path: ["head"],
};
// Base schema with form field builders (for UI generation)
export const newBuildBaseSchema = z.object({
export const newBuildBaseSchema = v.object({
buildToEditId: idConstantOptional(),
title: textField({ label: "labels.buildTitle", maxLength: 50 }),
// ... other fields
});
// Client schema with sync refinements only
export const newBuildSchema = newBuildBaseSchema.refine(
gearAllOrNoneRefine.fn,
gearAllOrNoneRefine.opts,
export const newBuildSchema = v.pipe(
newBuildBaseSchema,
superRefine((data, ctx) => {
if (!gearAllOrNoneRefine.fn(data)) {
ctx.addIssue({
message: gearAllOrNoneRefine.message,
path: gearAllOrNoneRefine.path,
});
}
}),
);
```
**Server schema (`feature-schemas.server.ts`)** - adds async validation:
```ts
import * as v from "valibot";
import { requireUser } from "~/features/auth/core/user.server";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import { superRefine, superRefineAsync } from "~/utils/schema";
import { gearAllOrNoneRefine, newBuildBaseSchema } from "./feature-schemas";
export const newBuildSchemaServer = newBuildBaseSchema
export const newBuildSchemaServer = v.pipeAsync(
newBuildBaseSchema,
// Reuse sync refinements from base
.refine(gearAllOrNoneRefine.fn, gearAllOrNoneRefine.opts)
superRefine((data, ctx) => {
if (gearAllOrNoneRefine.fn(data)) return;
ctx.addIssue({
message: gearAllOrNoneRefine.message,
path: gearAllOrNoneRefine.path,
});
}),
// Add async server-only validation
.refine(
async (data) => {
if (!data.buildToEditId) return true;
superRefineAsync(async (data, ctx) => {
if (!data.buildToEditId) return;
const user = requireUser();
const ownerId = await BuildRepository.ownerIdById(data.buildToEditId);
const user = requireUser();
const ownerId = await BuildRepository.ownerIdById(data.buildToEditId);
if (ownerId === user.id) return;
return ownerId === user.id;
},
{ message: "Not a build you own", path: ["buildToEditId"] },
);
ctx.addIssue({
message: "Not a build you own",
path: ["buildToEditId"],
});
}),
);
```
**Action using server schema:**
@@ -594,15 +614,15 @@ Check for duplicates in the database:
import { createTeamSchema } from "./feature-schemas";
import * as TeamRepository from "./TeamRepository.server";
export const createTeamSchemaServer = z.object({
...createTeamSchema.shape,
name: createTeamSchema.shape.name.refine(
async (name) => {
export const createTeamSchemaServer = v.objectAsync({
...createTeamSchema.entries,
name: v.pipeAsync(
createTeamSchema.entries.name,
v.checkAsync(async (name) => {
const teams = await TeamRepository.findAllUndisbanded();
const customUrl = mySlugify(name);
return !teams.some((team) => team.customUrl === customUrl);
},
{ message: "forms:errors.duplicateName" },
}, "forms:errors.duplicateName"),
),
});
```
@@ -612,18 +632,17 @@ export const createTeamSchemaServer = z.object({
For complex validation involving multiple fields:
```ts
export const scrimsNewFormSchema = z
.object({
export const scrimsNewFormSchema = v.pipe(
v.object({
at: datetime({ label: "labels.start" }),
maps: select({ label: "labels.maps", items: mapsItems }),
mapsTournamentId: customField({ initialValue: null }, id.nullable()),
})
.superRefine((data, ctx) => {
mapsTournamentId: customField({ initialValue: null }, v.nullable(id)),
}),
superRefine((data, ctx) => {
if (data.maps === "TOURNAMENT" && !data.mapsTournamentId) {
ctx.addIssue({
path: ["mapsTournamentId"],
message: "errors.tournamentMustBeSelected",
code: z.ZodIssueCode.custom,
});
}
@@ -631,10 +650,10 @@ export const scrimsNewFormSchema = z
ctx.addIssue({
path: ["mapsTournamentId"],
message: "errors.tournamentOnlyWhenMapsIsTournament",
code: z.ZodIssueCode.custom,
});
}
});
}),
);
```
## Translations
@@ -733,7 +752,7 @@ import {
### Schema (`feature-schemas.ts`)
```ts
import { z } from "zod";
import * as v from "valibot";
import {
textField,
textAreaOptional,
@@ -742,7 +761,7 @@ import {
stringConstant,
} from "~/form/fields";
export const createItemSchema = z.object({
export const createItemSchema = v.object({
_action: stringConstant("CREATE"),
name: textField({
label: "labels.itemName",

View File

@@ -16,15 +16,15 @@ One definition per route (or feature, when several routes share params), in a sh
```ts
// app/features/builds/builds-search-params.ts
import { z } from "zod";
import * as v from "valibot";
import * as SearchParams from "~/modules/search-params/search-params";
import { SP } from "~/modules/search-params/search-params";
export const buildsSearchParams = SearchParams.define({
limit: SP.param(z.number().int().min(1).max(100), { default: 24, loader: true }),
limit: SP.param(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100)), { default: 24, loader: true }),
f: SP.json(buildFiltersSchema, { default: [], resets: ["limit"], loader: true }),
focused: SP.param(z.enum(["1", "2", "3"]), { default: "1", loader: false }),
tournament: SP.param(z.string().max(100).nullable(), { loader: true }),
focused: SP.param(v.picklist(["1", "2", "3"]), { default: "1", loader: false }),
tournament: SP.param(v.nullable(v.pipe(v.string(), v.maxLength(100))), { loader: true }),
});
```
@@ -38,26 +38,26 @@ Options accepted by every declaration:
### `SP.param` and the derivation table
`SP.param(valueSchema, opts)` is the canonical declaration. The value schema is plain zod — all validation lives there, and shared schemas from `app/utils/zod.ts` plug in directly. The URL encoding is derived from the schema's type:
`SP.param(valueSchema, opts)` is the canonical declaration. The value schema is plain valibot — all validation lives there, and shared schemas from `app/utils/schema.ts` plug in directly. The URL encoding is derived from the schema's type:
| Schema base type | URL encoding |
| --- | --- |
| `z.string()`, string enums/literals | as-is |
| `z.number()`, number enums/literals (incl. `numericEnum`) | `String(n)` |
| `z.boolean()` | `"true"` / `"false"` only |
| `z.array(item)` | repeated keys (`?id=1&id=2`); invalid members are dropped, not the whole array |
| `.nullable()` wrapper | unwrapped; `null` encodes as param absent. `default` is omitted (it is always `null`; passing anything else throws). `.optional()` is rejected — `.nullable()` is the project-wide convention |
| refinements (`.min`, `.max`, `.refine`, …) | validation only; a failing value resolves to the default |
| `v.string()`, string picklists/literals | as-is |
| `v.number()`, number enums (incl. `numericEnum`) | `String(n)` |
| `v.boolean()` | `"true"` / `"false"` only |
| `v.array(item)` | repeated keys (`?id=1&id=2`); invalid members are dropped, not the whole array |
| `v.nullable()` wrapper | unwrapped; `null` encodes as param absent. `default` is omitted (it is always `null`; passing anything else throws). `v.optional()` is rejected — `v.nullable()` is the project-wide convention |
| validations in a pipe (`v.minValue`, `v.maxLength`, `v.check`, …) | validation only; a failing value resolves to the default |
Derivation is closed, not best-effort: shapes outside this table (objects, mixed-type unions, transforms, `z.preprocess`) are a `define()`-time error. Those use the explicit helpers:
Derivation is closed, not best-effort: shapes outside this table (objects, mixed-type unions, transforms, `preprocess`) are a `define()`-time error. Those use the explicit helpers:
| Helper | Encoding |
| --- | --- |
| `SP.json(schema, opts)` | `JSON.stringify` in a single value — for objects and whole-array-as-one-param values |
| `SP.custom(codec, opts)` | anything — pass a `z.codec(z.string(), valueSchema, { decode, encode })` directly |
| `SP.custom(codec, opts)` | anything — pass a `codec(valueSchema, { decode, encode })` (from the search-params module; `decode` returns `undefined` for malformed input, `nullableCodec` widens with `null`) |
| `SP.page(opts?)` | the paginated route's `page` param (1-based, `loader: true`, default `1`, `max` overridable) |
Note: schemas built with `z.preprocess` (like `weaponSplId`, `stageId` in `app/utils/zod.ts`) are pipes and rejected — use the inner schema (`numericEnum(mainWeaponIds)`, `numericEnum(stageIds)`) since string→number conversion is the codec's job.
Note: schemas carrying a transform are rejected — those built with `preprocess` (like `weaponSplId`, `stageId` in `app/utils/schema.ts`) and those built with `coerceNumber` (like `id`). Use the inner schema instead (`numericEnum(mainWeaponIds)`, `numericEnum(stageIds)`, `v.pipe(v.number(), v.integer(), v.minValue(1))`) since string→number conversion is the codec's job.
### Compression