web-forms-zod-validation-46ec00 — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited web-forms-zod-validation-46ec00 (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Quick Guide: A schema is declared once and the TypeScript type derived from it withz.infer, so the rule and the type cannot drift.safeParsereturns a result object where invalid input is expected andparsethrows where it is a bug;refineandsuperRefinecarry rules the built-in checks cannot express, andtransformconverts during validation — which splitsz.inputfromz.output. On v4 the string formats moved to the top level (z.email(),z.url(),z.iso.*) and the v3 method chains are deprecated rather than removed;flatten(),format()andmerge()have replacements, and reference.md carries the full migration list.
Detailed Resources:
.catch() fallbacks, readonly, recursive schemasz.infer is the only type helperneeded, and the parsed value has the shape the caller passed in. Follow examples/core.md.
transform, a coerce or a default makes the input andoutput types differ, so a function taking pre-validation data types its parameter z.input and its return z.output. Follow examples/transforms.md.
<critical_requirements>
Reach for `safeParse` wherever invalid input is expected. It returns a result object rather than throwing, so the failure is a branch rather than a catch, and result.error.issues carries the field paths a form needs. Keep parse for config and internal data, where invalid means a bug.
Derive the type with `z.infer<typeof schema>`. A hand-written interface beside a schema is a second declaration of the same thing, and the two drift in the direction that leaves the type claiming more than the schema checks.
Validate where untrusted data enters — API responses, form input, config, URL params. A shape change caught at the boundary names the field that moved; caught later it surfaces as an undefined property several frames away.
Name the validation limits. .min(MIN_USERNAME_LENGTH) says what the number is for, and the same constant reaches the error message so the two cannot disagree.
</critical_requirements>
Auto-detection: zod, z.object, z.infer, z.input, z.output, safeParse, safeParseAsync, parse, parseAsync, refine, superRefine, ctx.addIssue, transform, discriminatedUnion, z.coerce, z.pipe, z.catch, z.brand, z.lazy, z.email, z.url, z.uuid, z.iso, z.flattenError, z.treeifyError, z.strictObject, z.looseObject, ZodError
Applies to:
DateHandled elsewhere:
slot, and how that connection is made is settled by whatever owns it.
they describe the same record, and generating one from the other is that tool's concern.
<philosophy>
TypeScript checks the code you compile; a schema checks the data you receive. The two meet at the boundary, and the point of deriving the type from the schema is that only one of them can be wrong.
const UserSchema = z.object({ name: z.string(), email: z.email() });
type User = z.infer<typeof UserSchema>;Written the other way round — an interface, and a schema maintained beside it — a field added to the interface and forgotten in the schema type-checks everywhere while validating nothing.
The corollary is where _not_ to reach for a schema. Data that has already crossed a boundary has been checked, and re-validating it inside a function the compiler already governs buys nothing and costs a parse on every call.
</philosophy>
<patterns>
The constant appears in the check and in the message, so a change to the limit updates both.
const MIN_USERNAME_LENGTH = 3;
const MAX_USERNAME_LENGTH = 50;
const UserSchema = z.object({
username: z
.string()
.min(
MIN_USERNAME_LENGTH,
`Username must be at least ${MIN_USERNAME_LENGTH} characters`,
)
.max(
MAX_USERNAME_LENGTH,
`Username cannot exceed ${MAX_USERNAME_LENGTH} characters`,
),
email: z.email("Invalid email format"),
});
type User = z.infer<typeof UserSchema>;Full code: examples/core.md Pattern 1
The result is a discriminated union on success, so the failure branch narrows to an error and the success branch to typed data.
const result = UserSchema.safeParse(data);
if (!result.success) {
const { fieldErrors } = z.flattenError(result.error);
return { success: false, errors: fieldErrors };
}
return { success: true, user: result.data };z.flattenError gives one level of field-to-messages, which is what a flat form needs. z.treeifyError preserves nesting for anything deeper.
Full code: examples/core.md Pattern 2
refine adds a condition to one value. superRefine sees the whole object and chooses which path the error attaches to, which is what puts a mismatch message on the field the user must fix.
const PasswordFormSchema = z
.object({
password: z.string().min(MIN_PASSWORD_LENGTH),
confirmPassword: z.string(),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Passwords do not match",
path: ["confirmPassword"],
});
}
});Full code: examples/core.md Pattern 5
Once a schema transforms, it has two types. z.infer returns the output, so a function receiving raw data types its parameter z.input.
const DateSchema = z.iso.datetime().transform((str) => new Date(str));
type DateInput = z.input<typeof DateSchema>; // string
type DateOutput = z.output<typeof DateSchema>; // Date.transform() runs after every other check on the same schema, so a rule that must inspect the converted value goes after a .pipe() rather than before the transform.
Full code: examples/transforms.md Pattern 7, and Pattern 9 for the pipe
Where the variants share a literal field, naming it lets Zod check one branch instead of all of them.
const NotificationSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("email"), email: z.email(), subject: z.string() }),
z.object({ type: z.literal("sms"), phone: z.string(), message: z.string() }),
z.object({
type: z.literal("push"),
deviceId: z.string(),
title: z.string(),
}),
]);A plain z.union tries every member and reports the combined failure, which reads as "invalid input"; the discriminated form names the variant and the field inside it, and narrows in a switch.
Full code: examples/core.md Pattern 4
One base schema, and the read, create and update shapes derived from it — so a field added to the base reaches all three.
const UserSchema = BaseEntitySchema.extend({
email: z.email(),
name: z.string(),
});
const CreateUserSchema = UserSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
});
const UpdateUserSchema = CreateUserSchema.partial();
const UserSummarySchema = UserSchema.pick({ id: true, name: true });.extend() is unavailable on a schema that already carries .refine(), so extend first and refine last.
Full code: examples/core.md Pattern 3
URL params and form fields arrive as strings whatever they represent. z.coerce converts before checking, so the rules read as the types they are about.
const PaginationSchema = z.object({
page: z.coerce.number().int().positive().default(DEFAULT_PAGE),
limit: z.coerce
.number()
.int()
.positive()
.max(MAX_LIMIT)
.default(DEFAULT_LIMIT),
});z.coerce.boolean() is Boolean(value), so every non-empty string is true — including "false". Use z.stringbool() where the string carries the intent.
Full code: examples/transforms.md Pattern 8
Four distinct claims about an absent value; picking by meaning keeps the type honest.
const ProfileSchema = z.object({
name: z.string(), // required
bio: z.string().optional(), // may be omitted — string | undefined
avatar: z.url().nullable(), // explicitly empty — string | null
nickname: z.string().nullish(), // either — string | null | undefined
theme: z.string().default("light"), // absent becomes "light" — always string
});.default() fills a missing key, so the output type stays non-optional while the input type does not — one more reason z.input and z.output diverge.
</patterns>
<red_flags>
Breaks at runtime:
parse on user input — it throws on the expected case, and a catch block receives the exceptionafter the branch that could have read error.issues has been skipped. Use safeParse.
parseAsync orsafeParseAsync for any schema containing one.
.extend() on a schema that already carries .refine() — the refined schema no longer exposes it.Extend first, refine last.
.refine(fn, (val) => ({ message })) — the function form of the second argument is gone. UsesuperRefine where the message depends on the value.
z.record(valueSchema) with one argument — both the key and the value schema are required now.Surprising behaviour:
z.strictObject() rejects them andz.looseObject() keeps them; the plain object schema quietly drops them, which hides a renamed API field.
z.coerce.boolean() coerces "false" to true, and z.coerce.date() accepts everythingnew Date() does — including strings that parse to a date nobody meant. Where the input is supposed to be ISO, z.iso.datetime() says so and rejects the rest.
z.email() rejects the empty string, so an optional field that submits "" fails. Allow itexplicitly or normalise "" to undefined before parsing.
superRefine rule is skipped, not failed, when a key it reads is absent — the object validates."Invalid input" messages reach the user unchanged wherever a check has no message.ctx.path is gone from superRefine — ctx.addIssue({ path: [...] }) still works..flatten() → z.flattenError(), .format() → z.treeifyError(), .merge() →.extend().
Worked before/after code for the most common of these is in reference.md.
</red_flags>
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.