| name | frontend/type-safety-standards |
| description | Enforce type safety: Zod runtime validation at boundaries, type guards for discriminated unions, avoid any/unknown, validate before asserting. Optimize Zod with .pick()/.partial() on hot paths. Use when implementing API integrations, form handling, or state management with strict typing. |
Frontend Type Safety Standards
Pattern: Strict TypeScript + Runtime Validation + Type Guards
Use TypeScript strict mode with runtime validation and type guards for comprehensive type safety across frontend code.
Core Architecture
- TypeScript Configuration: Strict mode enabled with all strict flags
- Runtime Validation: Zod schemas for API responses, user input, and external data
- Type Guards: Custom guards for discriminated unions and complex types
- Type Definitions: Centralized types in
@litskills/types library
- Validation Strategy: Validate at boundaries (API, user input, storage)
Strict TypeScript Configuration
Enable all strict flags in tsconfig:
strict: true
noUncheckedIndexedAccess: true
noImplicitAny: true
strictNullChecks: true
- Module resolution:
"bundler" for frontend, "nodenext" for Node.js
Runtime Validation with Zod
Define Schema
import { z } from 'zod';
export const StoryDraftSchema = z.object({
id: z.uuid(),
title: z.string().min(1),
content: z.object({
screens: z.array(
z.object({
id: z.uuid(),
elements: z.array(z.unknown()),
}),
),
}),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export type StoryDraft = z.infer<typeof StoryDraftSchema>;
Validate User Input
const UserInputSchema = z.object({
title: z.string().min(1, 'Title is required').max(100, 'Title too long'),
description: z.string().max(500, 'Description too long').optional(),
});
function handleSubmit(formData: unknown) {
const result = UserInputSchema.safeParse(formData);
if (!result.success) {
const errors = result.error.flatten().fieldErrors;
setErrors(errors);
return;
}
const validData = result.data;
submitData(validData);
}
Type Guards for Discriminated Unions
Define Discriminated Union
export type StoryElement = { type: 'image'; id: string; src: string; alt?: string } | { type: 'video'; id: string; src: string; poster?: string } | { type: 'text'; id: string; content: string; fontSize: number };
Create Type Guard
export function isImageElement(element: StoryElement): element is Extract<StoryElement, { type: 'image' }> {
return element.type === 'image';
}
export function isVideoElement(element: StoryElement): element is Extract<StoryElement, { type: 'video' }> {
return element.type === 'video';
}
export function isTextElement(element: StoryElement): element is Extract<StoryElement, { type: 'text' }> {
return element.type === 'text';
}
Use Type Guard with Exhaustiveness Check
function renderElement(element: StoryElement) {
if (isImageElement(element)) {
return <img src={element.src} alt={element.alt} />;
}
if (isVideoElement(element)) {
return <video src={element.src} poster={element.poster} />;
}
if (isTextElement(element)) {
return <p style={{ fontSize: element.fontSize }}>{element.content}</p>;
}
const _exhaustive: never = element;
return _exhaustive;
}
Centralized Type Definitions
Core Types Library (@litskills/types)
export type WorkspaceId = string & { readonly __brand: 'WorkspaceId' };
export type StoryId = string & { readonly __brand: 'StoryId' };
export type ElementId = string & { readonly __brand: 'ElementId' };
export interface Workspace {
id: WorkspaceId;
name: string;
ownerId: string;
}
export type ResultSuccess<T> = { success: true; data: T };
export type ResultError = { success: false; error: { message: string } };
export type Result<T> = ResultSuccess<T> | ResultError;
Feature-Specific Types
import type { StoryElement, ElementId } from '@litskills/types';
export interface CanvasState {
zoom: number;
tool: 'select' | 'text' | 'image' | 'video';
isPlaying: boolean;
}
export interface SelectionState {
selectedElementId: ElementId | null;
selectedElementIds: ElementId[];
}
Avoiding any and unknown
DON'T: Use any
function handleData(data: any) {
return data.foo.bar;
}
DO: Use Proper Types
interface ExpectedData {
foo: { bar: string };
}
function handleData(data: ExpectedData) {
return data.foo.bar;
}
DO: Use unknown with Validation
function handleData(data: unknown) {
const parsed = ExpectedDataSchema.safeParse(data);
if (!parsed.success) {
throw new Error('Invalid data');
}
return parsed.data.foo.bar;
}
DO: Use Generic Constraints
function processElement<T extends StoryElement>(element: T): T {
return { ...element, processed: true } as T;
}
Type Assertion Guidelines
DON'T: Assert Without Validation
const data = await fetch('/api/data').then((res) => res.json());
const story = data as Story;
DO: Validate Before Asserting
const data = await fetch('/api/data').then((res) => res.json());
const result = StorySchema.safeParse(data);
if (!result.success) {
throw new Error('Invalid story data');
}
const story = result.data;
DO: Use Assertion Functions
function assertIsElement(value: unknown): asserts value is StoryElement {
const result = StoryElementSchema.safeParse(value);
if (!result.success) {
throw new Error('Not a valid element');
}
}
const data: unknown = getDataFromSomewhere();
assertIsElement(data);
Zod Performance Optimization
When to Optimize
| Validation Frequency | Schema Complexity | Optimize? | Strategy |
|---|
| Once per page load | Simple (<10 fields) | No | Use full validation |
| Once per page load | Complex (50+ fields) | Maybe | Use .pick() for critical path |
| Multiple times (list) | Any | Yes | Use .pick() or cache |
| Real-time (10+ per sec) | Any | Yes | Use cache + lazy |
| High-frequency (60+/sec) | Any | Yes | Skip Zod, use type guards |
Strategy 1: Partial Validation with .pick()
const StoryDraftSchema = z.object({
id: z.uuid(),
title: z.string().min(1),
content: z.object({
screens: z.array(
z.object({
id: z.uuid(),
elements: z.array(StoryElementSchema),
}),
),
}),
metadata: z.object({
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
version: z.number(),
}),
});
const StoryDraftListSchema = StoryDraftSchema.pick({
id: true,
title: true,
}).extend({
content: z.unknown(),
metadata: z.unknown(),
});
Strategy 2: Validation Caching
const validationCache = new Map<string, z.infer<typeof StoryElementSchema>>();
function validateElementCached(data: unknown, cacheKey: string) {
if (validationCache.has(cacheKey)) {
return { success: true, data: validationCache.get(cacheKey)! };
}
const result = StoryElementSchema.safeParse(data);
if (result.success) {
validationCache.set(cacheKey, result.data);
}
return result;
}
Strategy 3: Lazy Validation
interface LazyValidated<T> {
raw: unknown;
validated?: T;
schema: z.ZodType<T>;
}
function createLazyValidation<T>(raw: unknown, schema: z.ZodType<T>): LazyValidated<T> {
return { raw, schema };
}
function getLazyValue<T>(lazy: LazyValidated<T>): T {
if (!lazy.validated) {
lazy.validated = lazy.schema.parse(lazy.raw);
}
return lazy.validated;
}
Strategy 4: Use Coercion
const SlowSchema = z.object({
count: z
.string()
.refine((val) => !isNaN(Number(val)))
.transform(Number),
});
const FastSchema = z.object({
count: z.coerce.number(),
enabled: z.coerce.boolean(),
});
Strategy 5: Schema Precompilation
async function handleRequest(data: unknown) {
const schema = z.object({ id: z.string() });
return schema.parse(data);
}
const REQUEST_SCHEMA = z.object({ id: z.string() });
async function handleRequest(data: unknown) {
return REQUEST_SCHEMA.parse(data);
}
High-Frequency Operations (60+/sec)
For canvas drag and similar operations, skip Zod entirely:
function isValidCanvasPosition(data: unknown): data is { x: number; y: number } {
return typeof data === 'object' && data !== null && 'x' in data && 'y' in data && typeof data.x === 'number' && typeof data.y === 'number';
}
Performance Benchmark Results
| Scenario | Full | Optimized | Improvement |
|---|
| List of 100 stories (full schema) | 250ms | - | Baseline |
| List of 100 stories (pick id+title) | - | 15ms | 16x faster |
| Single story (with caching) | 5ms | 0.1ms | 50x faster |
| 1000 coerce validations | 45ms | 12ms | 3.75x faster |
| Schema per-request (1000x) | 150ms | 8ms | 18x faster |
Testing Type Safety
describe('Type Safety', () => {
it('should reject invalid element types', () => {
const element: StoryElement = {
type: 'image',
id: '123',
src: 'image.jpg',
};
const invalid: StoryElement = {
type: 'video',
id: '456',
alt: 'Invalid',
};
});
});
Risks and Mitigations
| Risk | Mitigation |
|---|
| Type Assertions Bypass Safety | Code review, prefer type guards, validate before asserting |
any Escape Hatch Overuse | ESLint no-explicit-any rule, code review checklist |
| Schema and Type Drift | Co-locate schemas and types, use z.infer, automated tests |
| Over-Validation Performance | Validate at boundaries only, cache results, profile performance |
Implementation Examples
libs/frontend/types/src/index.ts - Core types
libs/frontend/story-state/src/types/index.ts - State types with branded IDs
libs/db/src/schema/*.ts - Database schemas with Drizzle types