15 KiB
Frontend Schemas Module
Overview
The Schemas module provides Zod validation schemas for form validation across the frontend application. These schemas define data validation rules, generate TypeScript types, and provide initial form values for entity forms.
Location: frontend/src/schemas/
Total Files: 6 files (~257 LOC)
Library: Zod - TypeScript-first schema validation
Architecture
frontend/src/schemas/
├── index.ts (9 LOC) # Central exports
├── userSchema.ts (80 LOC) # User validation (create/update)
├── assetSchema.ts (85 LOC) # Asset validation
├── projectSchema.ts (31 LOC) # Project validation
├── tourPageSchema.ts (35 LOC) # Tour page validation
└── roleSchema.ts (17 LOC) # Role validation
Related Schema: frontend/src/lib/offlineDb/schema.ts - Dexie IndexedDB schema (separate concern)
Schema Pattern
Each schema file follows a consistent pattern:
/**
* 1. Zod schema definition
*/
export const entitySchema = z.object({
fieldName: z.string().min(1, 'Error message'),
// ...
});
/**
* 2. TypeScript type inference
*/
export type EntityFormData = z.infer<typeof entitySchema>;
/**
* 3. Initial form values
*/
export const entityInitialValues: EntityFormData = {
fieldName: '',
// ...
};
Schema Files
1. User Schema (userSchema.ts)
Purpose: Validation for user creation and update forms.
Lines: 80 LOC
Schemas
// User creation - requires email, optional password
export const userCreateSchema = z.object({
firstName: z.string().max(255, 'First name too long').optional().or(z.literal('')),
lastName: z.string().max(255, 'Last name too long').optional().or(z.literal('')),
phoneNumber: z.string().max(50, 'Phone number too long').optional().or(z.literal('')),
email: z.string().email('Invalid email address').min(1, 'Email is required'),
password: z.string().min(6, 'Password must be at least 6 characters').optional().or(z.literal('')),
disabled: z.boolean().default(false),
avatar: z.array(z.unknown()).optional(),
app_role: z.unknown().optional().nullable(),
custom_permissions: z.array(z.unknown()).optional(),
});
// User update - same fields, password optional
export const userUpdateSchema = z.object({
// Same structure as userCreateSchema
});
Exports
| Export | Type | Description |
|---|---|---|
userCreateSchema |
ZodObject | Create user validation |
userUpdateSchema |
ZodObject | Update user validation |
UserCreateFormData |
Type | Inferred create form type |
UserUpdateFormData |
Type | Inferred update form type |
userInitialValues |
Object | Default form values |
Field Validations
| Field | Validation | Error Message |
|---|---|---|
firstName |
max(255), optional | "First name too long" |
lastName |
max(255), optional | "Last name too long" |
phoneNumber |
max(50), optional | "Phone number too long" |
email |
email(), min(1) | "Invalid email address", "Email is required" |
password |
min(6), optional | "Password must be at least 6 characters" |
disabled |
boolean, default(false) | - |
avatar |
array, optional | - |
app_role |
unknown, optional, nullable | - |
custom_permissions |
array, optional | - |
2. Asset Schema (assetSchema.ts)
Purpose: Validation for asset upload and edit forms.
Lines: 85 LOC
Schema
export const assetSchema = z.object({
project: z.unknown().optional().nullable(),
name: z.string().min(1, 'Name is required').max(255, 'Name too long'),
asset_type: z.enum(['image', 'video', 'audio', 'file']),
type: z.enum([
'general', 'icon', 'background_image', 'audio',
'video', 'transition', 'logo', 'favicon', 'document',
]).default('general'),
cdn_url: z.string().url('Invalid URL').optional().or(z.literal('')),
storage_key: z.string().max(500, 'Storage key too long').optional().or(z.literal('')),
mime_type: z.string().max(100, 'MIME type too long').optional().or(z.literal('')),
size_mb: z.coerce.number().min(0, 'Size cannot be negative').optional().or(z.literal('')),
width_px: z.coerce.number().int().min(0, 'Width cannot be negative').optional().or(z.literal('')),
height_px: z.coerce.number().int().min(0, 'Height cannot be negative').optional().or(z.literal('')),
duration_sec: z.coerce.number().min(0, 'Duration cannot be negative').optional().or(z.literal('')),
checksum: z.string().max(255, 'Checksum too long').optional().or(z.literal('')),
is_public: z.boolean().default(false),
is_deleted: z.boolean().default(false),
deleted_at_time: z.date().optional().nullable(),
});
Exports
| Export | Type | Description |
|---|---|---|
assetSchema |
ZodObject | Asset validation schema |
AssetFormData |
Type | Inferred form type |
assetInitialValues |
Object | Default form values |
Enum Values
asset_type:
image- Image files (PNG, JPG, WebP, etc.)video- Video files (MP4, WebM, etc.)audio- Audio files (MP3, WAV, etc.)file- Generic files (documents, etc.)
type (usage category):
general- Default categoryicon- UI iconsbackground_image- Page backgroundsaudio- Background audiovideo- Video contenttransition- Transition videoslogo- Brand logosfavicon- Site faviconsdocument- Documents/PDFs
Coercion Pattern
Number fields use z.coerce for form input handling:
// Converts string input to number, validates, allows empty string
size_mb: z.coerce
.number()
.min(0, 'Size cannot be negative')
.optional()
.or(z.literal(''))
3. Project Schema (projectSchema.ts)
Purpose: Validation for project creation and edit forms.
Lines: 31 LOC
Schema
export const projectSchema = z.object({
name: z.string().min(1, 'Name is required').max(255, 'Name too long'),
slug: z.string()
.max(255, 'Slug too long')
.regex(
/^[a-z0-9_-]*$/i,
'Slug can only contain letters, numbers, dashes, underscores',
)
.optional()
.or(z.literal('')),
description: z.string().max(5000, 'Description too long').optional().or(z.literal('')),
});
Exports
| Export | Type | Description |
|---|---|---|
projectSchema |
ZodObject | Project validation schema |
ProjectFormData |
Type | Inferred form type |
projectInitialValues |
Object | Default form values |
Slug Validation
The slug field uses regex validation for URL-safe strings:
.regex(/^[a-z0-9_-]*$/i, 'Slug can only contain letters, numbers, dashes, underscores')
Valid: my-project, project_123, MyProject
Invalid: my project, project@123, project/name
4. Tour Page Schema (tourPageSchema.ts)
Purpose: Validation for tour page creation and edit forms.
Lines: 35 LOC
Schema
export const tourPageSchema = z.object({
project: z.unknown().optional().nullable(),
title: z.string().max(255, 'Title too long').optional().or(z.literal('')),
slug: z.string()
.max(255, 'Slug too long')
.regex(
/^[a-z0-9_-]*$/i,
'Slug can only contain letters, numbers, dashes, underscores',
)
.optional()
.or(z.literal('')),
background_asset: z.unknown().optional().nullable(),
audio_asset: z.unknown().optional().nullable(),
is_start_page: z.boolean().default(false),
sort_order: z.coerce.number().int().min(0).optional().or(z.literal('')),
});
Exports
| Export | Type | Description |
|---|---|---|
tourPageSchema |
ZodObject | Tour page validation schema |
TourPageFormData |
Type | Inferred form type |
tourPageInitialValues |
Object | Default form values |
Initial Values
export const tourPageInitialValues: TourPageFormData = {
project: null,
title: '',
slug: '',
background_asset: null,
audio_asset: null,
is_start_page: false,
sort_order: '',
};
5. Role Schema (roleSchema.ts)
Purpose: Validation for role creation and edit forms.
Lines: 17 LOC
Schema
export const roleSchema = z.object({
name: z.string().min(1, 'Name is required').max(255, 'Name too long'),
permissions: z.array(z.unknown()).optional(),
});
Exports
| Export | Type | Description |
|---|---|---|
roleSchema |
ZodObject | Role validation schema |
RoleFormData |
Type | Inferred form type |
roleInitialValues |
Object | Default form values |
Initial Values
export const roleInitialValues: RoleFormData = {
name: '',
permissions: [],
};
6. Index File (index.ts)
Purpose: Central re-export for all schemas.
Lines: 9 LOC
export * from './userSchema';
export * from './projectSchema';
export * from './assetSchema';
export * from './roleSchema';
export * from './tourPageSchema';
Zod Validation Patterns
1. Optional with Empty String
Allow field to be empty or have valid value:
// Accepts: undefined, '', or valid string
fieldName: z.string().optional().or(z.literal(''))
2. Required String
Field must have non-empty value:
// Fails on empty string
name: z.string().min(1, 'Name is required')
3. Email Validation
Built-in email format validation:
email: z.string().email('Invalid email address').min(1, 'Email is required')
4. Enum Validation
Restrict to specific values:
asset_type: z.enum(['image', 'video', 'audio', 'file'])
5. Number Coercion
Convert string input to number:
// Input: "123" → number 123
// Input: "" → literal ''
size_mb: z.coerce.number().min(0).optional().or(z.literal(''))
6. Regex Pattern
Validate string format:
slug: z.string().regex(/^[a-z0-9_-]*$/i, 'Error message')
7. Nullable Relation
Optional reference to another entity:
project: z.unknown().optional().nullable()
8. Boolean with Default
Boolean field with default value:
disabled: z.boolean().default(false)
Type Inference
Zod schemas generate TypeScript types automatically:
// Define schema
const projectSchema = z.object({
name: z.string().min(1),
slug: z.string().optional(),
});
// Infer type
type ProjectFormData = z.infer<typeof projectSchema>;
// Equivalent to:
// type ProjectFormData = {
// name: string;
// slug?: string;
// }
Usage with Formik
Schemas can be integrated with Formik using adapters:
Option 1: Manual Validation
import { projectSchema, projectInitialValues } from '@/schemas';
const validate = (values) => {
const result = projectSchema.safeParse(values);
if (!result.success) {
const errors = {};
result.error.issues.forEach((issue) => {
errors[issue.path[0]] = issue.message;
});
return errors;
}
return {};
};
<Formik
initialValues={projectInitialValues}
validate={validate}
onSubmit={handleSubmit}
>
{/* form fields */}
</Formik>
Option 2: Zod-Formik Adapter
import { toFormikValidationSchema } from 'zod-formik-adapter';
import { projectSchema, projectInitialValues } from '@/schemas';
<Formik
initialValues={projectInitialValues}
validationSchema={toFormikValidationSchema(projectSchema)}
onSubmit={handleSubmit}
>
{/* form fields */}
</Formik>
Related: IndexedDB Schema
The lib/offlineDb/schema.ts file defines the Dexie.js IndexedDB schema for offline storage (separate from form validation):
class OfflineDatabase extends Dexie {
assets!: EntityTable<OfflineAsset, 'id'>;
projects!: EntityTable<OfflineProject, 'id'>;
downloadQueue!: EntityTable<DownloadQueueItem, 'id'>;
constructor() {
super(OFFLINE_CONFIG.dbName);
this.version(OFFLINE_CONFIG.dbVersion).stores({
assets: 'id, projectId, url, variantType, assetType, downloadedAt',
projects: 'id, slug, status, lastSyncedAt',
downloadQueue: 'id, projectId, status, priority, addedAt',
});
}
}
export const offlineDb = new OfflineDatabase();
Key Differences:
| Aspect | Zod Schemas | Dexie Schema |
|---|---|---|
| Purpose | Form validation | Database structure |
| Library | Zod | Dexie.js |
| Location | schemas/ |
lib/offlineDb/ |
| Runtime | Client-side validation | IndexedDB storage |
Schema Inventory
| File | LOC | Entity | Fields | Exports |
|---|---|---|---|---|
userSchema.ts |
80 | User | 9 fields | 5 (2 schemas, 2 types, 1 initial) |
assetSchema.ts |
85 | Asset | 14 fields | 3 (1 schema, 1 type, 1 initial) |
tourPageSchema.ts |
35 | TourPage | 7 fields | 3 (1 schema, 1 type, 1 initial) |
projectSchema.ts |
31 | Project | 3 fields | 3 (1 schema, 1 type, 1 initial) |
roleSchema.ts |
17 | Role | 2 fields | 3 (1 schema, 1 type, 1 initial) |
index.ts |
9 | - | - | Re-exports all |
| Total | 257 | 5 | 35 | 17 |
Best Practices
1. Consistent Error Messages
Use descriptive, user-friendly error messages:
// Good
name: z.string().min(1, 'Name is required').max(255, 'Name too long')
// Avoid
name: z.string().min(1).max(255) // Generic messages
2. Type-Safe Initial Values
Use inferred type for initial values:
export const projectInitialValues: ProjectFormData = {
name: '', // TypeScript enforces this matches schema
slug: '',
description: '',
};
3. Empty String Handling
For optional fields that may receive empty strings from forms:
// Allows both undefined and empty string
fieldName: z.string().optional().or(z.literal(''))
4. Relation Fields
Use z.unknown() for relation fields that hold entity references:
// Accepts any value - actual validation happens on backend
project: z.unknown().optional().nullable()
5. Number Input Handling
Use z.coerce for number fields from text inputs:
// Converts "123" to 123, validates as number
amount: z.coerce.number().min(0)
Related Documentation
- types-module.md - Entity type definitions
- factories-module.md - Form page factory (uses schemas)
- lib-module.md - Offline storage schema
- hooks-module.md - Form hooks
Adding New Schemas
To add validation for a new entity:
Step 1: Create Schema File
// schemas/widgetSchema.ts
import { z } from 'zod';
export const widgetSchema = z.object({
name: z.string().min(1, 'Name is required').max(255, 'Name too long'),
type: z.enum(['basic', 'advanced']).default('basic'),
config: z.record(z.unknown()).optional(),
});
export type WidgetFormData = z.infer<typeof widgetSchema>;
export const widgetInitialValues: WidgetFormData = {
name: '',
type: 'basic',
config: {},
};
Step 2: Export from Index
// schemas/index.ts
export * from './widgetSchema';
Step 3: Use in Form
import { widgetSchema, widgetInitialValues, type WidgetFormData } from '@/schemas';
// With validation
const validate = (values: WidgetFormData) => {
const result = widgetSchema.safeParse(values);
// ... handle errors
};