Pro features are free during beta

v1.2.5-PRICING-19
Web & Frontend • Engineering Documentation

Zod Schema Linter

This technical guide provides an in-depth analysis of the zod lint engine, best practices for implementation, and data security standards.

Zod Schema Linter: Catch the Validators You Forgot to Add

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.

Live Example: What the Linter Catches

// 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() }),
});

Why Syntax Linters Miss These Issues

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 Inference Dictionary: How It Works

// 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

No False Positives: Conservative by Design

// 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

Integration: Run the Linter in CI

// 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);
  });
});

Best Practices for Production Zod Schemas

  • Name fields after what they are, not what they store: A field named contact isn't linted for email — but contactEmail is. Descriptive names let the linter help you and make schemas self-documenting.
  • Always declare formats on string fields: 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.
  • Prefer .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.
  • Replace 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.

FAQ

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.

Developer FAQ

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.