Data Model handling in TypeScript


A good data modeling strategy should:

  • Validate at runtime, not just compile time — TypeScript types disappear at runtime; malformed API responses silently corrupt your app
  • Catch data issues early — missing or malformed data often goes unnoticed until it causes a bug downstream
  • Avoid over-permissive optionals — marking fields optional because they’re sometimes omitted hides real data integrity issues
  • Stay consistent across frontend and backend — drift between client expectations and server responses is a common source of bugs
  • Transform data at boundaries — dates arrive as ISO strings but should be Date objects in your code

Solution Overview

  • Shared schema package — define all models as Zod schemas in a shared package; both frontend and backend import from the same source
  • Validate at API boundaries — parse incoming and outgoing data on both sides to catch issues immediately
  • Different models for different purposes — separate schemas for summaries vs full objects, and for create vs patch vs fetch, to avoid lax validations that mask problems
  • Types derived from schemas — never define TypeScript types separately; always infer from Zod to maintain a single source of truth
  • Transform during parse — convert wire formats (ISO strings) to runtime types (Date objects) automatically

Single Source of Truth

All API models are defined as Zod schemas in packages/shared/src/schemas/. Both webapp and service import types and schemas from this package, eliminating type drift.

packages/shared/src/schemas/
├── common.ts           # Enums, Address, GPS, DateRange
├── location.ts         # Location, LocationSummary, DTOs
├── user.ts             # User, Doctor, MedicalStaff, DTOs
├── job.ts              # Job, JobSummary, DTOs
├── job-application.ts  # JobApplication, DTOs
├── contact.ts          # ContactInquiry, DTOs
└── index.ts            # Re-exports all schemas + types

Type Inference

Always infer TypeScript types from Zod schemas:

export const JobSchema = z.object({ ... });
export type Job = z.infer<typeof JobSchema>;

Never define types separately from schemas — this defeats the purpose of having a single source of truth.

Naming Conventions

SchemaTypeDescription
XxxSchemaXxxFull entity
XxxSummarySchemaXxxSummaryList/embedded version
CreateXxxSchemaCreateXxxDtoPOST request body
PatchXxxSchemaPatchXxxDtoPATCH request body

Schemas Also Transform

Zod schemas do more than validate — they can transform data from its wire format to the representation you want in code.

Enums

Use native TypeScript enums for values that need runtime iteration (e.g., Object.values()):

export enum DocumentationType {
  POLICE_CHECK = "Police Check",
  WORK_VISA = "Work Visa",
  // ...
}

export const DocumentationTypeSchema = z.nativeEnum(DocumentationType);

Use z.enum() for string literals that don’t need runtime iteration:

export const LocationTypeSchema = z.enum(["general-practice", "hospital"]);
export type LocationType = z.infer<typeof LocationTypeSchema>;

Timestamps

Timestamps use DateTimeSchema which transforms ISO strings to Date objects on parse:

// Wire format: ISO 8601 string ("2025-01-15T00:00:00.000Z")
// In code: native Date object

export const DateTimeSchema = z
  .string()
  .datetime()
  .transform((str) => new Date(str));

export const NullableDateTimeSchema = z
  .string()
  .datetime()
  .nullable()
  .transform((str) => (str ? new Date(str) : null));

export const EntityTimestampsSchema = z.object({
  createdAt: DateTimeSchema, // Date in code
  updatedAt: DateTimeSchema, // Date in code
});

Serialization: JSON.stringify() automatically converts Date objects back to ISO strings via Date.toJSON(), so no manual conversion is needed when sending requests.

const job: Job = { createdAt: new Date(), ... };
JSON.stringify(job);  // createdAt becomes "2025-01-15T00:00:00.000Z"

Date Strings

Use DateStringSchema for date-only values (no time component) — these remain as strings:

// Stays as string - no transform
export const DateStringSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);

// Example: date ranges use date strings
export const DateRangeSchema = z.object({
  startDate: DateStringSchema, // "2025-03-01"
  endDate: DateStringSchema, // "2025-03-31"
});
SchemaWire FormatIn Code
DateStringSchema"2025-01-15"string
DateTimeSchema"2025-01-15T00:00:00.000Z"Date
NullableDateTimeSchema"2025-01-15T00:00:00.000Z" or nullDate | null

Nullable vs Optional

  • nullable(): Field exists but can be null (database columns)
  • optional(): Field may be omitted (request DTOs)
  • nullish(): Either null or undefined
// Database entity - field always present, may be null
publishedAt: z.string().nullable();

// Request DTO - field may be omitted
publishedAt: z.string().nullable().optional();

Different Models for Different Purposes

Using a single schema for all purposes leads to overly permissive definitions that hide real data integrity issues. Instead, create purpose-specific schemas. Consider using .strict() on your schemas to reject unknown properties—variations in shape should be handled by different model types, not by allowing arbitrary extra fields.

Summary vs Full

Use different schemas for list endpoints vs detail endpoints:

VariantUsageCharacteristics
XxxSummaryList endpoints, embedded objectsReduced fields, no nested entities
XxxDetail endpointsAll fields, includes nested entities
// Summary - for lists (no nested objects)
export const JobSummarySchema = z.object({
  id: z.string().uuid(),
  title: z.string(),
  hourlyRate: z.number(),
  locationId: z.string().uuid(), // ID only
  // ... reduced fields
});

// Full - for detail views (includes nested objects)
export const JobSchema = JobSummarySchema.extend({
  description: z.string(),
  location: LocationSummarySchema.optional(), // Nested object
  suitability: z.array(DoctorJobSuitabilitySchema).optional(),
});

This avoids the trap of making fields optional “because they’re not always included” — instead, you have explicit schemas for each use case.

Create and Patch DTOs

Derive Create/Patch DTOs from the full schema when possible:

// Derive from full schema
export const CreateJobSchema = JobSchema.omit({
  id: true,
  createdAt: true,
  updatedAt: true,
  location: true, // Derived from locationId
  suitability: true, // Computed field
});

// Patch is partial of Create
export const PatchJobSchema = CreateJobSchema.partial();

Exception: Define the DTO explicitly when it differs significantly from the full model. Describing a schema through its differences to another can be more confusing than helpful when they share little in common.

// Explicit definition - create differs significantly from fetch
export const CreateJobSchema = z.object({
  locationId: z.string().uuid(),
  title: z.string(),
  description: z.string(),
  // ... no requiredDocumentation (set on location)
  // ... publishedAt is optional (defaults to null/draft)
});

Validate on Both Sides

Both frontend and backend import the same schemas from the shared package, ensuring consistent validation at every boundary.

Webapp (ApiClient)

Sending requests: Validate before sending (optional but catches errors early)

async createJob(data: CreateJobDto): Promise<Job> {
  const validatedData = CreateJobSchema.parse(data);  // Validate
  const response = await this.fetch(`/jobs`, {
    method: 'POST',
    body: JSON.stringify(validatedData),  // Date → ISO string automatically
  });
  const result = await response.json();
  return JobSchema.parse(result);  // Validate + transform response
}

Receiving responses: Always parse to validate and transform

async getJob(id: string): Promise<Job> {
  const response = await this.fetch(`/jobs/${id}`);
  const json = await response.json();
  // json.createdAt is "2025-01-15T00:00:00.000Z" (string)

  const job = JobSchema.parse(json);
  // job.createdAt is Date object

  return job;
}

For array responses, wrap the schema:

async getJobs(): Promise<Job[]> {
  const response = await this.fetch(`/jobs`);
  const result = await response.json();
  return z.array(JobSchema).parse(result);
}

Service (NestJS)

Use a ZodValidationPipe:

import { CreateJobSchema, type CreateJobDto } from "@myapp/shared";
import { ZodValidationPipe } from "../common/zod-validation.pipe";

@Post()
async create(
  @Body(new ZodValidationPipe(CreateJobSchema)) data: CreateJobDto
) {
  return this.jobsService.create(data);
}

The pipe returns a 400 Bad Request with detailed error messages on validation failure.