skills/bratsos/zodipus/zodipus-custom-schemas

zodipus-custom-schemas

SKILL.md

Custom JSON Schemas

Type your Prisma JSON fields with custom Zod schemas using @zodSchema annotations.

When to Apply

  • User has JSON fields in Prisma schema
  • User mentions @zodSchema annotation
  • User wants typed metadata, settings, or config fields
  • User asks about custom-schemas.ts
  • User needs validation for JSON data
  • User mentions "typed JSON" or "JSON validation"

The Problem

Prisma's Json type has no runtime type safety:

model Post {
  id       String @id
  metadata Json?  // Type: JsonValue (any)
}
// No type safety - anything goes
post.metadata = { random: 'stuff', 123: true };
post.metadata = 'just a string';
post.metadata = [1, 2, 3];

The Solution

Use @zodSchema to define exactly what your JSON should contain.

Step 1: Annotate in Prisma Schema

Add a triple-slash comment with @zodSchema SchemaName above your JSON field:

model Post {
  id        String  @id @default(cuid())
  title     String
  content   String?

  /// @zodSchema PostMetadataSchema
  metadata  Json?
}

model User {
  id       String @id @default(cuid())
  email    String @unique

  /// @zodSchema UserSettingsSchema
  settings Json   @default("{}")

  /// @zodSchema UserPreferencesSchema
  preferences Json?
}

Step 2: Run Generation

npx prisma generate

This creates a custom-schemas.ts file with placeholder schemas:

// generated/custom-schemas.ts
import { z } from 'zod';

// Placeholder - replace with your schema
export const PostMetadataSchema = z.unknown();
export const UserSettingsSchema = z.unknown();
export const UserPreferencesSchema = z.unknown();

Step 3: Define Your Schemas

Replace the placeholders with real schemas:

// generated/custom-schemas.ts
import { z } from 'zod';

export const PostMetadataSchema = z.object({
  tags: z.array(z.string()).default([]),
  views: z.number().int().nonnegative().default(0),
  featured: z.boolean().default(false),
  seo: z.object({
    title: z.string().max(60).optional(),
    description: z.string().max(160).optional(),
    keywords: z.array(z.string()).optional(),
  }).optional(),
});

export const UserSettingsSchema = z.object({
  theme: z.enum(['light', 'dark', 'system']).default('system'),
  notifications: z.object({
    email: z.boolean().default(true),
    push: z.boolean().default(false),
    sms: z.boolean().default(false),
  }).default({}),
  language: z.string().default('en'),
  timezone: z.string().default('UTC'),
});

export const UserPreferencesSchema = z.object({
  dashboard: z.object({
    layout: z.enum(['grid', 'list']).default('grid'),
    itemsPerPage: z.number().int().min(10).max(100).default(20),
  }).optional(),
  privacy: z.object({
    showEmail: z.boolean().default(false),
    showActivity: z.boolean().default(true),
  }).optional(),
});

Step 4: Use It

Now your schemas have full type safety:

import { PostSchema, UserSchema } from './generated';

// Validated with your custom schema
const post = PostSchema.parse({
  id: '123',
  title: 'My Post',
  content: 'Hello world',
  metadata: {
    tags: ['tutorial', 'prisma'],
    views: 100,
    featured: true,
    seo: {
      title: 'My Post - Tutorial',
      description: 'Learn about Prisma',
    },
  },
});

// TypeScript knows the shape
post.metadata.tags;        // string[]
post.metadata.views;       // number
post.metadata.seo?.title;  // string | undefined

Common Patterns

Settings Object

export const AppSettingsSchema = z.object({
  theme: z.enum(['light', 'dark', 'system']).default('system'),
  fontSize: z.enum(['small', 'medium', 'large']).default('medium'),
  compactMode: z.boolean().default(false),
  sidebar: z.object({
    collapsed: z.boolean().default(false),
    width: z.number().int().min(200).max(400).default(280),
  }).default({}),
});

Metadata with Timestamps

export const AuditMetadataSchema = z.object({
  createdBy: z.string().optional(),
  updatedBy: z.string().optional(),
  version: z.number().int().default(1),
  changelog: z.array(z.object({
    date: z.coerce.date(),
    user: z.string(),
    action: z.string(),
  })).default([]),
});

Configuration with Defaults

export const NotificationConfigSchema = z.object({
  enabled: z.boolean().default(true),
  channels: z.object({
    email: z.boolean().default(true),
    slack: z.boolean().default(false),
    webhook: z.string().url().optional(),
  }).default({}),
  frequency: z.enum(['instant', 'hourly', 'daily']).default('instant'),
  quietHours: z.object({
    enabled: z.boolean().default(false),
    start: z.string().regex(/^\d{2}:\d{2}$/).optional(),
    end: z.string().regex(/^\d{2}:\d{2}$/).optional(),
  }).optional(),
});

Array of Objects

export const ProductVariantsSchema = z.array(z.object({
  sku: z.string(),
  name: z.string(),
  price: z.number().positive(),
  stock: z.number().int().nonnegative(),
  attributes: z.record(z.string()).optional(),
}));

Union/Discriminated Union Types

// Simple union
export const PaymentMethodSchema = z.union([
  z.object({
    type: z.literal('card'),
    last4: z.string().length(4),
    brand: z.enum(['visa', 'mastercard', 'amex']),
    expiryMonth: z.number().int().min(1).max(12),
    expiryYear: z.number().int(),
  }),
  z.object({
    type: z.literal('bank'),
    bankName: z.string(),
    accountLast4: z.string().length(4),
  }),
  z.object({
    type: z.literal('paypal'),
    email: z.string().email(),
  }),
]);

// Discriminated union (recommended for type inference)
export const PaymentMethodSchema = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('card'),
    last4: z.string().length(4),
    brand: z.enum(['visa', 'mastercard', 'amex']),
  }),
  z.object({
    type: z.literal('bank'),
    bankName: z.string(),
    accountLast4: z.string().length(4),
  }),
]);

Recursive/Nested Structures

// Tree structure (menu, categories, etc.)
interface TreeNode {
  id: string;
  name: string;
  children?: TreeNode[];
}

export const TreeNodeSchema: z.ZodType<TreeNode> = z.lazy(() =>
  z.object({
    id: z.string(),
    name: z.string(),
    children: z.array(TreeNodeSchema).optional(),
  })
);

export const MenuSchema = z.array(TreeNodeSchema);

Flexible Key-Value Store

// For arbitrary key-value pairs
export const CustomFieldsSchema = z.record(
  z.string(),
  z.union([z.string(), z.number(), z.boolean(), z.null()])
);

// With constraints
export const LabeledFieldsSchema = z.record(
  z.string().regex(/^[a-z_][a-z0-9_]*$/), // lowercase snake_case keys
  z.string().max(500)
);

Best Practices

1. Always Use Defaults

Prevents null/undefined issues when creating new records:

export const SettingsSchema = z.object({
  theme: z.enum(['light', 'dark']).default('light'),
  notifications: z.boolean().default(true),
}).default({}); // Default for the entire object

2. Make Optional Fields Explicit

Be clear about what's required vs optional:

export const ProfileSchema = z.object({
  // Required
  displayName: z.string().min(1).max(50),

  // Optional (can be undefined)
  bio: z.string().max(500).optional(),

  // Nullable (can be null)
  avatarUrl: z.string().url().nullable(),

  // Optional with default
  isPublic: z.boolean().default(true),
});

3. Validate Constraints

Add business rule validations:

export const PriceSchema = z.object({
  amount: z.number().positive(),
  currency: z.enum(['USD', 'EUR', 'GBP']),
  discount: z.number().min(0).max(100).optional(),
}).refine(
  (data) => !data.discount || data.amount > 0,
  { message: 'Discount requires positive amount' }
);

4. Document with .describe()

Add descriptions for documentation:

export const ApiConfigSchema = z.object({
  endpoint: z.string().url().describe('API base URL'),
  timeout: z.number().int().positive().default(30000).describe('Request timeout in ms'),
  retries: z.number().int().min(0).max(5).default(3).describe('Number of retry attempts'),
});

Regeneration Behavior

When you run npx prisma generate:

  1. New @zodSchema annotations: Adds placeholder to custom-schemas.ts
  2. Existing schemas: Preserved - your definitions are NOT overwritten
  3. Removed annotations: Schema export remains (manual cleanup needed)

Troubleshooting

Schema Not Being Used

Ensure the annotation is a triple-slash comment:

// Wrong - single slash
// @zodSchema MySchema
metadata Json?

// Wrong - double slash doc comment
/** @zodSchema MySchema */
metadata Json?

// Correct - triple slash
/// @zodSchema MySchema
metadata Json?

Type Errors After Editing custom-schemas.ts

Run generation again to update model schemas:

npx prisma generate

Schema Name Must Match Export

The annotation name must exactly match the export:

/// @zodSchema PostMetadataSchema
metadata Json?
// Must match exactly
export const PostMetadataSchema = z.object({...});
Weekly Installs
3
Repository
bratsos/zodipus
GitHub Stars
1
First Seen
Feb 6, 2026
Installed on
opencode3
gemini-cli3
antigravity3
claude-code3
github-copilot3
codex3