From typescript-experts
Provides Zod schema validation patterns and TypeScript type inference for defining schemas, parsing data, validating forms, and runtime type checks with z.object, z.string, z.infer.
npx claudepluginhub jpoutrin/product-forge --plugin typescript-expertsThis skill uses the workspace's default tool permissions.
TypeScript-first schema declaration and validation library with static type inference.
Uses Zod schemas for runtime validation of APIs, forms, env vars, and external data while inferring TypeScript types as single source of truth.
Creates reusable Zod v4 schemas to validate API payloads, forms, and config inputs in TypeScript apps. Handles coercion, transforms, errors, and type inference for runtime type safety.
Share bugs, ideas, or general feedback.
TypeScript-first schema declaration and validation library with static type inference.
strict mode in tsconfig.jsonAlways define schemas before validation:
import { z } from "zod";
// Object schema
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
age: z.number().int().positive(),
role: z.enum(["admin", "user", "guest"]),
});
// Infer TypeScript type from schema
type User = z.infer<typeof UserSchema>;
.parse() - Throws ZodError on failure:
try {
const user = UserSchema.parse(data);
// user is typed as User
} catch (e) {
if (e instanceof z.ZodError) {
console.error(e.issues);
}
}
.safeParse() - Returns discriminated union (preferred):
const result = UserSchema.safeParse(data);
if (result.success) {
console.log(result.data); // typed User
} else {
console.error(result.error.issues);
}
Async variants - Required for async refinements/transforms:
await UserSchema.parseAsync(data);
await UserSchema.safeParseAsync(data);
// Basic primitives
z.string()
z.number()
z.bigint()
z.boolean()
z.date()
z.symbol()
z.undefined()
z.null()
z.void()
z.any()
z.unknown()
z.never()
// Coercion - converts input to target type
z.coerce.string() // String(input)
z.coerce.number() // Number(input)
z.coerce.boolean() // Boolean(input)
z.coerce.bigint() // BigInt(input)
z.coerce.date() // new Date(input)
z.string()
.min(1) // Minimum length
.max(255) // Maximum length
.length(10) // Exact length
.email() // Email format
.url() // URL format
.uuid() // UUID format
.cuid() // CUID format
.regex(/pattern/) // Custom regex
.startsWith("prefix")
.endsWith("suffix")
.includes("substring")
.trim() // Transform: trim whitespace
.toLowerCase() // Transform: lowercase
.toUpperCase() // Transform: uppercase
z.number()
.int() // Integer only
.positive() // > 0
.nonnegative() // >= 0
.negative() // < 0
.nonpositive() // <= 0
.gt(5) // > 5
.gte(5) // >= 5 (alias: .min())
.lt(10) // < 10
.lte(10) // <= 10 (alias: .max())
.multipleOf(5) // Divisible by 5
.finite() // Excludes Infinity
.safe() // Safe integer range
const PersonSchema = z.object({
name: z.string(),
age: z.number(),
});
// Make all properties optional
PersonSchema.partial();
// Make specific properties optional
PersonSchema.partial({ age: true });
// Make all properties required
PersonSchema.required();
// Pick specific properties
PersonSchema.pick({ name: true });
// Omit specific properties
PersonSchema.omit({ age: true });
// Extend with new properties
PersonSchema.extend({
email: z.string().email(),
});
// Strict mode - reject unknown keys
PersonSchema.strict();
// Passthrough - preserve unknown keys
PersonSchema.passthrough();
// Strip unknown keys (default behavior)
PersonSchema.strip();
// Array of strings
z.array(z.string())
.min(1) // At least 1 element
.max(10) // At most 10 elements
.length(5) // Exactly 5 elements
.nonempty(); // At least 1 element (typed)
// Alternative syntax
z.string().array();
// Tuple with fixed positions
z.tuple([
z.string(), // First element: string
z.number(), // Second element: number
]);
// Tuple with rest elements
z.tuple([z.string(), z.number()]).rest(z.boolean());
// Union types
z.union([z.string(), z.number()]);
// Shorthand
z.string().or(z.number());
// Discriminated unions (better error messages)
z.discriminatedUnion("type", [
z.object({ type: z.literal("email"), email: z.string() }),
z.object({ type: z.literal("phone"), phone: z.string() }),
]);
// Enum from array
z.enum(["admin", "user", "guest"]);
// Native enum
enum Role { Admin, User }
z.nativeEnum(Role);
// Optional - allows undefined
z.string().optional(); // string | undefined
// Nullable - allows null
z.string().nullable(); // string | null
// Both
z.string().nullish(); // string | null | undefined
// Default values
z.string().default("anonymous");
z.string().optional().default("anonymous");
// Catch - use default on parse failure
z.string().catch("fallback");
// Transform output type
const StringToNumber = z.string().transform((val) => parseInt(val, 10));
type Output = z.output<typeof StringToNumber>; // number
// Chain transforms
z.string()
.trim()
.toLowerCase()
.transform((val) => val.split(","));
// Preprocess input before validation
z.preprocess(
(val) => String(val),
z.string().min(1)
);
// Custom validation
z.string().refine(
(val) => val.length <= 255,
{ message: "String must be 255 chars or less" }
);
// Async refinement
z.string().refine(
async (val) => await checkUnique(val),
{ message: "Value must be unique" }
);
// Super refine for complex validations
z.object({
password: z.string(),
confirm: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirm) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Passwords don't match",
path: ["confirm"],
});
}
});
const result = schema.safeParse(data);
if (!result.success) {
// Access all issues
result.error.issues.forEach((issue) => {
console.log(issue.path); // Field path
console.log(issue.message); // Error message
console.log(issue.code); // Error code
});
// Flatten for form errors
const flat = result.error.flatten();
// { formErrors: string[], fieldErrors: { [key]: string[] } }
// Format for display
const formatted = result.error.format();
// { _errors: string[], field: { _errors: string[] } }
}
const CreateUserRequest = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(1).max(100),
});
// In Express/Fastify handler
const body = CreateUserRequest.parse(req.body);
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(1),
});
export const env = EnvSchema.parse(process.env);
const ContactForm = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email address"),
message: z.string().min(10, "Message must be at least 10 characters"),
});
// With React Hook Form
const { register, handleSubmit } = useForm({
resolver: zodResolver(ContactForm),
});
const ApiResponse = z.object({
data: z.array(UserSchema),
pagination: z.object({
page: z.number(),
total: z.number(),
}),
});
const response = await fetch("/api/users");
const json = await response.json();
const validated = ApiResponse.parse(json);
import { z } from "zod";
// Zod to JSON Schema
const jsonSchema = z.toJSONSchema(UserSchema);
// JSON Schema to Zod
const zodSchema = z.fromJSONSchema(jsonSchema);
// Bad - duplicates schema
interface User {
name: string;
age: number;
}
const UserSchema = z.object({
name: z.string(),
age: z.number(),
});
// Good - infer from schema
const UserSchema = z.object({
name: z.string(),
age: z.number(),
});
type User = z.infer<typeof UserSchema>;
// Bad - throws on invalid input
const user = UserSchema.parse(userInput);
// Good - handle errors gracefully
const result = UserSchema.safeParse(userInput);
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
// Bad - ambiguous errors
z.union([
z.object({ email: z.string() }),
z.object({ phone: z.string() }),
]);
// Good - clear error messages
z.discriminatedUnion("contactType", [
z.object({ contactType: z.literal("email"), email: z.string() }),
z.object({ contactType: z.literal("phone"), phone: z.string() }),
]);