DTO Sync Patterns - Quick Reference
When NOT to Use This Skill
-
Type generation setup - Use type-generation skill
-
Validation implementation - Use language-specific validation skills
-
API contract validation - Use openapi-contract skill
Sync Strategy Overview
┌─────────────────────────────────────────────────────────────────────┐ │ DTO SYNC STRATEGIES │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ 1. Schema-First (Recommended) │ │ ┌──────────────┐ │ │ │ OpenAPI Spec │───→ Generate Backend DTOs │ │ │ (Source) │───→ Generate Frontend Types │ │ └──────────────┘ │ │ │ │ 2. Backend-First │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ Backend DTOs │───→ │ OpenAPI Spec │───→ Frontend Types │ │ │ (Source) │ │ (Generated) │ │ │ └──────────────┘ └──────────────┘ │ │ │ │ 3. Shared Package (Monorepo) │ │ ┌──────────────┐ │ │ │ @shared/types│───→ Backend imports │ │ │ (TypeScript) │───→ Frontend imports │ │ └──────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘
Pattern 1: Schema-First
OpenAPI as Source of Truth
openapi.yaml - Single source of truth
components: schemas: CreateUserRequest: type: object required: - email - name properties: email: type: string format: email maxLength: 255 name: type: string minLength: 2 maxLength: 100 age: type: integer minimum: 0 maximum: 150
User:
type: object
properties:
id:
type: string
format: uuid
email:
type: string
name:
type: string
age:
type: integer
createdAt:
type: string
format: date-time
Generate for Backend (Java)
Generate Java DTOs from OpenAPI
npx @openapitools/openapi-generator-cli generate
-i openapi.yaml
-g spring
-o generated/java
--additional-properties=useJakartaEe=true
// Generated: CreateUserRequest.java @Generated public class CreateUserRequest { @NotNull @Email @Size(max = 255) private String email;
@NotNull
@Size(min = 2, max = 100)
private String name;
@Min(0)
@Max(150)
private Integer age;
// getters, setters...
}
Generate for Frontend (TypeScript)
Generate TypeScript types from OpenAPI
npx openapi-typescript openapi.yaml -o src/api/types.ts
// Generated: types.ts export interface components { schemas: { CreateUserRequest: { email: string; name: string; age?: number; }; User: { id?: string; email?: string; name?: string; age?: number; createdAt?: string; }; }; }
Pattern 2: Backend-First
Backend Generates OpenAPI
// Spring Boot with springdoc-openapi @Schema(description = "Request to create a new user") public record CreateUserRequest( @Schema(description = "User email", example = "john@example.com") @NotNull @Email @Size(max = 255) String email,
@Schema(description = "User name", example = "John Doe")
@NotNull
@Size(min = 2, max = 100)
String name,
@Schema(description = "User age", minimum = "0", maximum = "150")
@Min(0)
@Max(150)
Integer age
) {}
Export OpenAPI spec from running backend
curl http://localhost:8080/v3/api-docs > openapi.json
Generate frontend types
npx openapi-typescript openapi.json -o src/api/types.ts
NestJS with Swagger
// NestJS DTO with decorators @Schema({ description: 'Request to create a new user' }) export class CreateUserDto { @ApiProperty({ example: 'john@example.com' }) @IsEmail() @MaxLength(255) email: string;
@ApiProperty({ example: 'John Doe' }) @IsString() @Length(2, 100) name: string;
@ApiPropertyOptional({ minimum: 0, maximum: 150 }) @IsOptional() @IsInt() @Min(0) @Max(150) age?: number; }
Export from NestJS
(requires @nestjs/swagger setup)
curl http://localhost:3000/api-json > openapi.json
Pattern 3: Shared Package (Monorepo)
Project Structure
monorepo/ ├── packages/ │ ├── shared/ │ │ ├── package.json │ │ └── src/ │ │ ├── types/ │ │ │ ├── user.ts │ │ │ └── index.ts │ │ └── validation/ │ │ ├── user.ts │ │ └── index.ts │ ├── frontend/ │ │ └── package.json # depends on @shared │ └── backend/ │ └── package.json # depends on @shared └── package.json
Shared Types
// packages/shared/src/types/user.ts export interface User { id: string; email: string; name: string; age?: number; createdAt: Date; }
export interface CreateUserRequest { email: string; name: string; age?: number; }
export interface UpdateUserRequest { name?: string; age?: number; }
// Type guards export function isUser(obj: unknown): obj is User { return ( typeof obj === 'object' && obj !== null && 'id' in obj && 'email' in obj ); }
Shared Validation (Zod)
// packages/shared/src/validation/user.ts import { z } from 'zod';
export const CreateUserSchema = z.object({ email: z.string().email().max(255), name: z.string().min(2).max(100), age: z.number().int().min(0).max(150).optional(), });
export const UpdateUserSchema = z.object({ name: z.string().min(2).max(100).optional(), age: z.number().int().min(0).max(150).optional(), });
// Infer types from schemas export type CreateUserRequest = z.infer<typeof CreateUserSchema>; export type UpdateUserRequest = z.infer<typeof UpdateUserSchema>;
Frontend Usage
// packages/frontend/src/api/users.ts import type { User, CreateUserRequest } from '@shared/types'; import { CreateUserSchema } from '@shared/validation';
async function createUser(data: CreateUserRequest): Promise<User> { // Validate before sending const validated = CreateUserSchema.parse(data);
const response = await fetch('/api/users', { method: 'POST', body: JSON.stringify(validated), });
return response.json(); }
Backend Usage (Node.js)
// packages/backend/src/routes/users.ts import type { CreateUserRequest } from '@shared/types'; import { CreateUserSchema } from '@shared/validation';
app.post('/api/users', async (req, res) => { // Same validation as frontend const result = CreateUserSchema.safeParse(req.body);
if (!result.success) { return res.status(400).json({ code: 'VALIDATION_ERROR', details: result.error.issues, }); }
const user = await userService.create(result.data); res.json(user); });
Validation Sync
Zod (TypeScript Both Ends)
// Shared schema const UserSchema = z.object({ email: z.string().email(), name: z.string().min(2).max(100), });
// Frontend: Form validation const form = useForm({ resolver: zodResolver(UserSchema), });
// Backend: Request validation app.post('/users', (req, res) => { const result = UserSchema.safeParse(req.body); });
class-validator (NestJS) ↔ Zod (Frontend)
// Backend: class-validator class CreateUserDto { @IsEmail() @MaxLength(255) email: string;
@IsString() @Length(2, 100) name: string; }
// Frontend: Equivalent Zod schema const CreateUserSchema = z.object({ email: z.string().email().max(255), name: z.string().min(2).max(100), });
Java Bean Validation ↔ Zod (Frontend)
// Backend: Jakarta validation public record CreateUserRequest( @NotNull @Email @Size(max = 255) String email, @NotNull @Size(min = 2, max = 100) String name ) {}
// Frontend: Equivalent Zod const CreateUserSchema = z.object({ email: z.string().email().max(255), name: z.string().min(2).max(100), });
Transformation Patterns
Request Transformation
// Frontend form data → API request interface FormData { firstName: string; lastName: string; birthDate: Date; }
interface CreateUserRequest { name: string; // Concatenated age: number; // Calculated }
function toCreateUserRequest(form: FormData): CreateUserRequest {
const age = calculateAge(form.birthDate);
return {
name: ${form.firstName} ${form.lastName},
age,
};
}
Response Transformation
// API response → Frontend model interface UserResponse { id: string; created_at: string; // snake_case full_name: string; }
interface User { id: string; createdAt: Date; // camelCase fullName: string; }
function toUser(response: UserResponse): User { return { id: response.id, createdAt: new Date(response.created_at), fullName: response.full_name, }; }
Automatic Case Conversion
import camelcaseKeys from 'camelcase-keys'; import snakecaseKeys from 'snakecase-keys';
// Axios interceptor axios.interceptors.request.use((config) => { if (config.data) { config.data = snakecaseKeys(config.data, { deep: true }); } return config; });
axios.interceptors.response.use((response) => { if (response.data) { response.data = camelcaseKeys(response.data, { deep: true }); } return response; });
Validation Sync Report
DTO Sync Validation Report
CreateUserRequest
| Field | Backend | Frontend | Status |
|---|---|---|---|
| @Email @Size(max=255) | z.string().email().max(255) | OK | |
| name | @Size(min=2, max=100) | z.string().min(2).max(100) | OK |
| age | @Min(0) @Max(150) | z.number().min(0).max(150) | OK |
User Response
| Field | Backend | Frontend | Status |
|---|---|---|---|
| id | UUID | string | OK |
| createdAt | Instant | Date | OK (transformed) |
| name | String | string | OK |
Recommendations
- All validations are in sync
- Date transformation handled in response interceptor
Anti-Patterns
Anti-Pattern Why It's Bad Correct Approach
Manual type copying Drift over time Generate from schema
Different validation rules Inconsistent errors Share validation logic
No transformation layer Tight coupling Add DTOs for each layer
Ignoring optionality Runtime errors Match required/optional exactly
snake_case/camelCase mismatch Confusion Auto-transform consistently
Quick Troubleshooting
Issue Likely Cause Solution
Type mismatch Manual sync drift Regenerate from schema
Validation passes frontend, fails backend Different rules Align validation schemas
Missing required field Optionality mismatch Check OpenAPI required array
Date parsing error String vs Date Add transformation
Case mismatch snake_case vs camelCase Add case conversion
Related Skills
-
OpenAPI Contract
-
Type Generation
-
Error Contract