Claude Code with Valibot: Tiny Schema Validation for TypeScript
Why Valibot without CLAUDE.md erodes the bundle-size advantage
Valibot is the smallest, most tree-shakable schema validation library available to TypeScript developers in 2026. The library is sub-2kb in real-world bundles when used correctly, the types are accurate, and the API is functional rather than method-chained. The problem is that Claude Code does not understand Valibot's modular architecture: it tends to write Zod-shaped code with Valibot imports, which silently pulls in the entire library and erases the bundle size advantage that made Valibot worth choosing in the first place.
The most common Claude defaults that hurt Valibot bundles: importing everything from the top-level package (import * as v from "valibot"), using monolithic schema constructors instead of pipe composition, mixing parse and safeParse semantics in the same code path, omitting the resolver setup when integrating with form libraries, and reaching for Zod-style refinements that have a different signature in Valibot. None of these surface as TypeScript errors because Valibot accepts the patterns silently and just ships more code than needed.
This guide covers the CLAUDE.md configuration that locks Claude Code into Valibot's correct model: per-function imports for tree-shaking, pipe composition for type narrowing, safeParse for non-throwing flows, async parsing for async refinements, and the integration patterns with React Hook Form and Drizzle that keep the bundle lean. If you are choosing between Valibot and Zod for a Next.js project, Claude Code with Zod covers the alternative; this guide assumes you have picked Valibot for the bundle reason and want Claude to keep it that way.
The Valibot CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Valibot integration it needs to declare: the package version, the import style, the pipe composition pattern, the parse vs safeParse policy, and the hard rules that block the mistakes Claude makes most often.
# Valibot validation rules
## Stack
- valibot ^0.42.x or ^1.x, TypeScript 5.x strict
- React Hook Form via @hookform/resolvers/valibot (if forms used)
- drizzle-valibot for Drizzle ORM schema sync (if Drizzle used)
## Import style (CRITICAL for tree-shaking)
- ALWAYS named imports: import { object, string, email, parse } from "valibot"
- NEVER namespace import: import * as v from "valibot" (defeats tree-shaking)
- For frequently-mixed imports, group at the top of the file
- If a schema file uses 30+ imports, split into multiple schema files
## Schema composition
- Use pipe() to compose validators: pipe(string(), email(), maxLength(255))
- Pipe steps run in order, stop on first failure
- Object schemas: object({ field: pipe(...), ... })
- Optional fields: optional(schema), nullable(schema), nullish(schema)
- Discriminated unions: variant("type", [object1, object2])
## Parse vs SafeParse
- parse(schema, input): throws on failure, returns typed output
- safeParse(schema, input): returns { success, output, issues } - NEVER throws
- USE parse: at boundaries where invalid data is a bug (server-internal)
- USE safeParse: at boundaries where invalid data is normal (user input, API)
## Async validation
- parseAsync / safeParseAsync for schemas with async pipe steps
- Async pipe steps: checkAsync, rawTransformAsync, etc
- NEVER call parse() on a schema that has async steps (will throw)
## Hard rules
- NEVER use namespace imports (import * as v from "valibot")
- NEVER call parse() inside a try/catch when safeParse() would be more honest
- NEVER duplicate schemas: derive related schemas via partial, pick, omit
- NEVER format issues manually, use flatten() or summarize()
- ALWAYS export the inferred type: type T = InferOutput<typeof schema>
Three rules here prevent the majority of bundle and correctness issues Claude generates without them.
The named imports rule is the most impactful for bundle size. Valibot's entire architectural advantage rests on every validator being a separate function that tree-shakers can prune. A namespace import (import * as v from "valibot") pins every export to the bundle because the bundler cannot prove which functions are accessed via the dynamic v object. Switching from v.object({ email: v.pipe(v.string(), v.email()) }) to object({ email: pipe(string(), email()) }) typically drops 30kb to 60kb from the production bundle.
The pipe composition rule is the most impactful for type safety. Method-chained validators (Zod-style .email().max(255)) cannot easily mix different return types. Pipe-based validators (pipe(string(), email(), maxLength(255))) read each step's output as the next step's input, which lets transformers narrow types as they validate. The pattern is unfamiliar to Claude because it has seen far more Zod examples than Valibot examples.
The parse vs safeParse rule prevents a class of confusing flows. parse throws, safeParse returns a result object. Wrapping parse in try/catch is a code smell: if the caller expects to handle invalid input, safeParse makes that intent explicit. The rule frames the distinction by responsibility: bugs throw, user input returns a result.
Install and basic schemas
Install Valibot:
npm i valibot
A basic object schema:
// src/schemas/user.ts
import { object, string, email, minLength, maxLength, pipe, optional, type InferOutput } from "valibot";
export const UserSchema = object({
id: pipe(string(), minLength(1)),
email: pipe(string(), email("Invalid email"), maxLength(255)),
name: pipe(string(), minLength(1, "Name required"), maxLength(100)),
bio: optional(pipe(string(), maxLength(500))),
});
export type User = InferOutput<typeof UserSchema>;
Three observations:
Named imports only. Every function is imported by name. Bundlers can drop the validators the file does not use.
pipefor composition. Each field schema is a pipe of validators.string()is the base type,email()andmaxLength(255)are refinements. The order matters: type validators (string,number,boolean) must come first; refinements come after.InferOutputfor the type.type User = InferOutput<typeof UserSchema>gives the TypeScript type{ id: string; email: string; name: string; bio?: string }. There are alsoInferInputfor the pre-transform type andInferIssuefor the issue type.
A more complex schema with transforms and refinements:
// src/schemas/order.ts
import {
object,
string,
number,
array,
pipe,
minLength,
minValue,
transform,
check,
type InferOutput,
} from "valibot";
const LineItemSchema = object({
sku: pipe(string(), minLength(1)),
quantity: pipe(number(), minValue(1)),
price: pipe(number(), minValue(0)),
});
export const OrderSchema = pipe(
object({
customerId: string(),
items: pipe(array(LineItemSchema), minLength(1, "Order must have at least one item")),
discountCode: pipe(
string(),
transform((code) => code.toUpperCase()),
),
}),
check(
(order) => order.items.reduce((sum, i) => sum + i.quantity * i.price, 0) > 0,
"Order total must be positive",
),
);
export type Order = InferOutput<typeof OrderSchema>;
Three more patterns shown:
Nested schemas.
LineItemSchemais defined separately and used as the element type of an array.transformfor normalisation.discountCodeis uppercased before further validation runs.checkfor cross-field validation. The outerpipe(object(...), check(...))wraps the object schema with a refinement that runs after the object is fully parsed and validated.
Parse vs safeParse
The two parse entry points have different ergonomics. Choose based on whether invalid input is normal or exceptional.
import { parse, safeParse } from "valibot";
import { UserSchema } from "@/schemas/user";
// parse(): throws ValiError on failure
function trustedTransform(raw: unknown) {
const user = parse(UserSchema, raw);
// user is fully typed and validated
return user.email.toLowerCase();
}
// safeParse(): returns a result object
function userInputHandler(raw: unknown) {
const result = safeParse(UserSchema, raw);
if (!result.success) {
return { error: result.issues };
}
return { user: result.output };
}
For API route handlers, safeParse is almost always correct:
// src/app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { safeParse, flatten } from "valibot";
import { UserSchema } from "@/schemas/user";
export async function POST(req: NextRequest) {
const body = await req.json();
const result = safeParse(UserSchema, body);
if (!result.success) {
return NextResponse.json(
{ error: "Validation failed", issues: flatten(result.issues) },
{ status: 400 },
);
}
const user = await createUser(result.output);
return NextResponse.json(user);
}
flatten(result.issues) converts the raw issue array into a structured object grouped by field path, which is the format React Hook Form (and most form libraries) expect.
Add a parse pattern to CLAUDE.md:
## Parse vs SafeParse selection
Use parse() when:
- Input is server-internal and invalid means a bug
- Throwing is the right contract (e.g. boot-time config)
- Caller wraps with global error handler that reports to Sentry
Use safeParse() when:
- Input is from a user, network, or any external source
- Caller wants typed access to issues for field-level error display
- Result will be returned to the client
NEVER wrap parse() in try/catch as a workaround for safeParse(). Refactor to safeParse().
Error formatting
Valibot's issues array is precise but verbose. Three helpers convert it to formats consumers actually need.
import { safeParse, flatten, summarize } from "valibot";
import { UserSchema } from "@/schemas/user";
const result = safeParse(UserSchema, { email: "not-an-email", name: "" });
if (!result.success) {
// flatten(): groups issues by field path
// Output: { nested: { email: ["Invalid email"], name: ["Name required"] } }
const flat = flatten(result.issues);
// summarize(): single string with all messages
// Output: "Invalid email\nName required"
const text = summarize(result.issues);
// Raw issues: full structural information
// Useful for custom error display logic
const raw = result.issues;
}
For React Hook Form integration via the resolver:
npm i @hookform/resolvers
import { useForm } from "react-hook-form";
import { valibotResolver } from "@hookform/resolvers/valibot";
import { UserSchema, type User } from "@/schemas/user";
export function UserForm() {
const { register, handleSubmit, formState: { errors } } = useForm<User>({
resolver: valibotResolver(UserSchema),
mode: "onBlur",
defaultValues: {
id: "",
email: "",
name: "",
},
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("email")} type="email" />
{errors.email && <p>{errors.email.message}</p>}
<input {...register("name")} type="text" />
{errors.name && <p>{errors.name.message}</p>}
<button type="submit">Save</button>
</form>
);
}
The resolver bridges Valibot's issue format to React Hook Form's expected error shape. The form receives field-level errors without manual flattening.
For more on the form patterns that pair with Valibot, Claude Code with React Hook Form covers the register vs Controller decision tree.
Async validation
Some refinements need to call the network: checking username availability, looking up an external service, hitting a database for uniqueness. Valibot's async API mirrors the sync API but uses parseAsync and async pipe steps.
import { object, string, pipe, email, checkAsync, parseAsync, safeParseAsync, type InferOutput } from "valibot";
const checkUsernameAvailable = async (username: string) => {
const res = await fetch(`/api/check-username?u=${encodeURIComponent(username)}`);
const { available } = await res.json();
return available;
};
export const SignupSchema = object({
email: pipe(string(), email()),
username: pipe(
string(),
checkAsync(checkUsernameAvailable, "Username is already taken"),
),
});
export type Signup = InferOutput<typeof SignupSchema>;
// Usage:
async function handleSignup(input: unknown) {
const result = await safeParseAsync(SignupSchema, input);
if (!result.success) {
return { error: result.issues };
}
return { signup: result.output };
}
checkAsync accepts an async predicate that returns a boolean. The pipe step runs only if the previous steps passed, which means the async network call is skipped when the type or earlier refinements already failed.
Calling parse (sync) on a schema that contains checkAsync (async) throws an error at runtime. The CLAUDE.md rule against this saves debugging time.
Add async validation to CLAUDE.md:
## Async validation
- parseAsync(schema, input) and safeParseAsync(schema, input) for async schemas
- Async pipe steps: checkAsync, rawTransformAsync, partialCheckAsync
- NEVER call parse() or safeParse() on a schema with async steps (throws at runtime)
- Async checks run AFTER sync checks pass (efficient short-circuit)
- For debounced UI validation, debounce in the consuming hook, not in the schema
Schema reuse with partial, pick, omit
Avoid duplicating schemas. Valibot exposes derivation helpers that build related schemas from a base.
import { object, string, pipe, email, minLength, partial, pick, omit, type InferOutput } from "valibot";
export const UserSchema = object({
id: pipe(string(), minLength(1)),
email: pipe(string(), email()),
name: pipe(string(), minLength(1)),
password: pipe(string(), minLength(8)),
});
// All fields optional (for PATCH endpoints)
export const UserUpdateSchema = partial(UserSchema);
// Subset of fields (for signup)
export const SignupSchema = pick(UserSchema, ["email", "name", "password"]);
// Drop sensitive fields (for public read)
export const PublicUserSchema = omit(UserSchema, ["password"]);
export type User = InferOutput<typeof UserSchema>;
export type UserUpdate = InferOutput<typeof UserUpdateSchema>;
export type Signup = InferOutput<typeof SignupSchema>;
export type PublicUser = InferOutput<typeof PublicUserSchema>;
Four schemas from one base. Type inference flows correctly through each derivation. No copy-paste, no drift when the source schema changes.
Add reuse helpers to CLAUDE.md:
## Schema reuse helpers
- partial(schema): all fields become optional
- pick(schema, ["field1", "field2"]): subset
- omit(schema, ["field1"]): subset without those fields
- required(schema): inverse of partial
- merge: not provided in core, use object spread: object({ ...schema.entries, newField: ... })
NEVER duplicate schemas with copy-paste. Always derive from a base.
Drizzle integration
If you are using Drizzle ORM, drizzle-valibot generates Valibot schemas from your database table definitions, keeping the two in sync.
npm i drizzle-valibot
// src/db/schema.ts
import { pgTable, text, integer, timestamp } from "drizzle-orm/pg-core";
import { createInsertSchema, createSelectSchema } from "drizzle-valibot";
export const users = pgTable("users", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
name: text("name").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
age: integer("age"),
});
export const InsertUserSchema = createInsertSchema(users);
export const SelectUserSchema = createSelectSchema(users);
createInsertSchema generates a Valibot schema where columns with defaults are optional. createSelectSchema generates the read-side schema. Both stay in sync with the table definition automatically.
For more on the Drizzle integration patterns, Claude Code with Drizzle covers the migration and query layer.
Looking to ship type-safe validation without bundle bloat? Get Claudify. Pre-built CLAUDE.md templates for Valibot and every major TypeScript tool, ready to drop into your project.
Bundle size verification
The whole point of choosing Valibot is the bundle size. Verify it with a build report.
npx @next/bundle-analyzer
Or for non-Next projects:
npx rollup-plugin-visualizer dist
The Valibot footprint should be sub-2kb in a typical app. If you see 30kb+, the imports are wrong somewhere. Search for import \* as in the codebase:
grep -r "import \* as.*from .valibot." src/
Any match is a bundle-size regression. Refactor to named imports.
A comparison of typical bundle costs for the most-used validation libraries:
| Library | Typical bundle cost | Tree-shaking quality |
|---|---|---|
| Valibot (named imports) | 1-2kb | Excellent |
| Valibot (namespace import) | 25-40kb | None |
| Zod | 12-15kb | Limited |
| Yup | 30-40kb | Limited |
| Joi | 70-90kb | None (Node-only) |
| ArkType | 8-12kb | Moderate |
The Valibot advantage only holds if the import style is correct. Claude generating namespace imports erases the entire reason for choosing Valibot.
Add a bundle audit to CLAUDE.md:
## Bundle audit
- Run bundle analyzer after every schema addition: npm run build:analyze
- Valibot footprint target: under 5kb for typical apps
- If footprint > 10kb: search for `import * as` against valibot, refactor to named
- If footprint > 20kb: investigate which validators are imported but unused
- Pre-commit hook (optional): rg "import \\* as.*from .valibot." --type ts && exit 1
Common Claude Code mistakes with Valibot
Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.
1. Namespace import
Claude generates: import * as v from "valibot"; v.object({ email: v.pipe(v.string(), v.email()) }).
Correct pattern: import { object, pipe, string, email } from "valibot"; object({ email: pipe(string(), email()) }).
2. Mixed parse and safeParse semantics
Claude generates: try { const data = parse(schema, input); } catch (e) { return { error: e } }.
Correct pattern: const result = safeParse(schema, input); if (!result.success) return { error: result.issues };.
3. Manual issue formatting
Claude generates: result.issues.map((i) => i.path.join(".") + ": " + i.message).join("\n").
Correct pattern: flatten(result.issues) or summarize(result.issues).
4. parse() on async schema
Claude generates: parse(schemaWithCheckAsync, input) and gets a runtime throw.
Correct pattern: await safeParseAsync(schemaWithCheckAsync, input).
5. Schema duplication
Claude generates: copy-paste of UserSchema to make UserUpdateSchema with manually optional fields.
Correct pattern: const UserUpdateSchema = partial(UserSchema).
6. Missing InferOutput export
Claude generates: the schema only, leaving consumers to redefine the TypeScript type.
Correct pattern: export type User = InferOutput<typeof UserSchema> next to every schema.
| Mistake | Symptom | Fix |
|---|---|---|
| Namespace import | Bundle 25-40kb instead of 1-2kb | Named imports |
| Mixed parse/safeParse | Confusing flows | safeParse for user input |
| Manual issue formatting | Inconsistent error UI | flatten/summarize |
| parse on async schema | Runtime throw | safeParseAsync |
| Duplicate schemas | Drift over time | partial/pick/omit |
| No InferOutput export | Duplicate type definitions | Export type next to schema |
Add a common mistakes section to CLAUDE.md with these six pairs. The bundle size mistake is the most expensive of the six because it cancels the only reason to pick Valibot over Zod.
Building validation that keeps the bundle lean
The Valibot CLAUDE.md in this guide produces validation code where every import is named for tree-shaking, schemas compose through pipe rather than method chains, parse and safeParse are chosen by responsibility, error formatting uses the built-in helpers, schema reuse goes through partial and pick instead of copy-paste, and async refinements always go through safeParseAsync.
The underlying principle is the same as any library integration with Claude Code. Valibot without a CLAUDE.md produces code that works in the sense that validation runs, but produces bundles that defeat the only reason to choose Valibot over Zod, schemas that drift apart over time, and error formats that vary between endpoints. The CLAUDE.md template removes each failure mode by making the correct pattern the only pattern Claude can generate.
For the next layer up from validation, Valibot pairs well with Claude Code with React Hook Form for form resolvers, Claude Code with Drizzle for ORM-synced schemas, and Claude Code with Next.js for API route validation. Claudify includes a Valibot CLAUDE.md template with the named-import policy, pipe composition patterns, parse selection rules, async validation flows, and all six common-mistake pairs pre-configured.
Get Claudify. Ship production-ready Valibot integrations with Claude Code from the first session.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify