Pro features are free during beta
Pro features are free during beta
This technical guide provides an in-depth analysis of the zod lint engine, best practices for implementation, and data security standards.
Most Zod linters check syntax — whether your schema compiles. This linter checks semantics: whether a field named email actually has .email(), whether createdAt has .datetime(), whether avatarUrl has .url(). These are the validators that are easy to forget but that silently let invalid data through at runtime. The linter runs entirely in your browser — paste your schema and get instant, actionable suggestions.
// Input — a real-world schema with common oversights
const userSchema = z.object({
id: z.string().uuid(), // ✓ uuid declared
email: z.string(), // ⚠ semantic-email: missing .email()
avatar_url: z.string(), // ⚠ semantic-url: missing .url()
createdAt: z.string(), // ⚠ semantic-datetime: missing .datetime()
bio: z.string().optional().nullable(), // ℹ prefer-nullish: use .nullish()
metadata: z.any(), // ⚠ no-any: z.any() skips validation
});
// Linter output
// Line 3 ⚠ semantic-email — 'email' looks like an email, but z.string() has no .email()
// Suggestion: z.string().email()
//
// Line 4 ⚠ semantic-url — 'avatar_url' looks like a URL, but z.string() has no .url()
// Suggestion: z.string().url()
//
// Line 5 ⚠ semantic-datetime — 'createdAt' looks like a timestamp, but z.string() has no .datetime()
// Suggestion: z.string().datetime()
//
// Line 6 ℹ prefer-nullish — Use .nullish() instead of .optional().nullable()
// Suggestion: z.string().nullish()
//
// Line 7 ⚠ no-any — z.any() skips validation entirely. Give it a concrete shape.
// Fixed schema — clean, passes linter
const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
avatar_url: z.string().url(),
createdAt: z.string().datetime(),
bio: z.string().nullish(),
metadata: z.object({ source: z.string(), version: z.number() }),
});
Tools like eslint-plugin-zod catch structural problems — calling a method that doesn't exist, or using the wrong argument type. They can't catch missing validators because that requires knowing what a field means, not just what type it has. A field named email typed as z.string() is syntactically valid; the Zod runtime accepts it without complaint. The bug shows up later, when "not-an-email" passes validation and reaches your database or email sender.
// The linter uses field-name patterns — the same signals the inference engine
// uses when GENERATING a Zod schema from JSON. In reverse, they become lint rules.
// email / mail → .email()
email: z.string() // ⚠ missing .email()
userEmail: z.string() // ⚠ missing .email()
contactMail: z.string() // ⚠ missing .email()
// url / uri / href / link / endpoint / avatar / thumbnail / image / photo → .url()
profileUrl: z.string() // ⚠ missing .url()
thumbnailSrc: z.string() // ⚠ missing .url()
avatarImage: z.string() // ⚠ missing .url()
// uuid / guid (exact match only — not every id is a uuid) → .uuid()
uuid: z.string() // ⚠ missing .uuid()
guid: z.string() // ⚠ missing .uuid()
userId: z.string() // ✓ not flagged — could be cuid, nanoid, etc.
// createdAt / updatedAt / deletedAt / publishedAt / ... / timestamp → .datetime()
createdAt: z.string() // ⚠ missing .datetime()
updated_at: z.string() // ⚠ missing .datetime()
timestamp: z.string() // ⚠ missing .datetime()
seat: z.string() // ✓ not flagged — ends in "at" but isn't a timestamp
// Semantic rules only fire on z.string()-rooted chains.
// Non-string types are never flagged, no matter what the field is named.
emailVerified: z.boolean() // ✓ no warning — it's a boolean, not a string
emailCount: z.number() // ✓ no warning — it's a number
avatarWidth: z.number() // ✓ no warning — image dimension, not a URL
createdAt: z.date() // ✓ no warning — already a Date type
// Semantic rules are silent when the format is already declared.
email: z.string().email() // ✓ already has .email()
email: z.email() // ✓ Zod v4 shorthand — also fine
avatar_url: z.string().url() // ✓ already has .url()
uuid: z.string().cuid() // ✓ cuid/ulid/cuid2 also silence the uuid rule
createdAt: z.string().datetime() // ✓ already has .datetime()
createdAt: z.iso.datetime() // ✓ Zod v4 ISO datetime — also fine
// TypeMorph's linter is also available as a library function.
// Use it to enforce schema quality in your test suite.
import { lintZod } from '@typemorph/zod-linter'; // (coming soon)
// In a Vitest / Jest test:
describe('Zod schema quality', () => {
it('has no lint warnings', () => {
const schemaSource = readFileSync('./src/schemas/user.ts', 'utf8');
const diagnostics = lintZod(schemaSource);
// Print warnings as readable output if any
if (diagnostics.length > 0) {
console.table(diagnostics.map(d => ({
line: d.line,
field: d.field,
rule: d.rule,
suggestion: d.suggestion,
})));
}
expect(diagnostics.filter(d => d.severity === 'warning')).toHaveLength(0);
});
});
contact isn't linted for email — but contactEmail is. Descriptive names let the linter help you and make schemas self-documenting.z.string() alone accepts any string — empty, 10,000 characters, malformed. Even without the linter, add .email(), .url(), .min(1), .max(N). Validation failures caught at the schema boundary are always cheaper than failures caught in business logic..nullish() over .optional().nullable(): Both accept null and undefined, but .nullish() is shorter, more readable, and the Zod-idiomatic way. The linter flags the verbose form as an info diagnostic.z.any() with a real shape: z.any() disables all validation for that field. If the shape is truly unknown at compile time, use z.record(z.string(), z.unknown()) or z.unknown(). If it's just inconvenient to type, model it — the one-time cost saves runtime surprises.Q: Does the linter modify my schema?
A: No. It reports diagnostics with a suggested fix string, but it never edits your code. You apply the changes.
Q: Why doesn't the linter flag userId: z.string()?
A: Not every id-suffixed field is a UUID — your system might use cuid, nanoid, integer IDs, or prefixed IDs like "usr_4421". The UUID rule only triggers on fields literally named uuid or guid, where the intent is unambiguous. Flagging userId would produce too many false positives and erode trust in the linter.
Q: What about Zod v4?
A: The linter understands both v3 and v4 syntax. z.email() (v4 shorthand), z.string().email() (v3+v4), and z.iso.datetime() (v4) all silence the relevant rules correctly.
Is the processing local-only?
Absolutely. TypeMorph operates entirely within your browser's sandbox. We use Web Workers for high-performance computation without ever transmitting your JSON, SQL, or API data to a remote server.
Can I use this for enterprise projects?
Yes. The tool is designed for professional software engineers who require GDPR compliance and data privacy. It is trusted by developers at top-tier startups and financial institutions.
Why pasting proprietary company data into third-party web tools is a major liability, and how to stay safe.
A deep dive into combining Zod, React Query, and TypeScript for bulletproof API integration.
Code generation is just the beginning. Discover how a schema-first approach can eliminate 90% of your integration bugs.