Type Safety Flow
One of the key features of the LMS is end-to-end type safety. This document explains how types flow from the database through the backend to the frontend, ensuring compile-time safety across the entire stack.
Overview
The type safety flow ensures that:
- API contracts are defined once and shared
- Changes to backend APIs immediately affect frontend types
- Compile-time errors catch API mismatches
- Developers get auto-complete for API calls
- Refactoring is safer and easier
Type Flow Diagram
┌──────────────────────────────────────────────────┐
│ 1. Database Entities (TypeORM) │
│ packages/db/src/entities/*.entity.ts │
│ @Entity() decorators + TypeScript types │
└────────────────┬─────────────────────────────────┘
│
↓
┌──────────────────────────────────────────────────┐
│ 2. Backend Controllers (TSOA) │
│ apps/client-backend/src/controllers/*.ts │
│ @Route, @Get, @Post decorators │
│ Return types from entities │
└────────────────┬─────────────────────────────────┘
│
↓
┌──────────────────────────────────────────────────┐
│ 3. OpenAPI Specification (Auto-generated) │
│ apps/client-backend/lib/swagger.json │
│ Generated by TSOA from controller types │
└────────────────┬─────────────────────────────────┘
│
↓
┌──────────────────────────────────────────────────┐
│ 4. TypeScript Types (Auto-generated) │
│ packages/api/lib/api.gen.ts │
│ Generated by openapi-typescript │
└────────────────┬─────────────────────────────────┘
│
↓
┌──────────────────────────────────────────────────┐
│ 5. Frontend API Client │
│ packages/api/src/client.ts │
│ Type-safe openapi-fetch client │
└────────────────┬─────────────────────────────────┘
│
↓
┌──────────────────────────────────────────────────┐
│ 6. React Components │
│ apps/client-frontend/src/**/*.tsx │
│ Type-safe API calls with full IntelliSense │
└──────────────────────────────────────────────────┘Step-by-Step Process
Step 1: Define Database Entities
Database entities are defined using TypeORM decorators in the @repo/db package.
File: packages/db/src/entities/Course.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from "typeorm";
@Entity()
export class Course {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column()
code: string;
@Column({ type: "text", nullable: true })
description: string | null;
@OneToMany(() => Module, (module) => module.course)
modules: Module[];
}Key Points:
- TypeORM decorators define both database schema AND TypeScript types
- Nullable fields are explicitly typed (e.g.,
string | null) - Relationships are type-safe
Step 2: Create Backend Controllers
Controllers use TSOA decorators to define API endpoints and their types.
File: apps/client-backend/src/controllers/course.controller.ts
import { Controller, Get, Post, Route, Body, Path } from "tsoa";
import { Course } from "@repo/db/entities/Course.entity";
interface CreateCourseRequest {
name: string;
code: string;
description?: string;
}
@Route("api/courses")
export class CourseController extends Controller {
/**
* Get all courses
*/
@Get()
public async getCourses(): Promise<Course[]> {
// Implementation
return courses;
}
/**
* Get a single course by ID
*/
@Get("{id}")
public async getCourse(@Path() id: number): Promise<Course> {
// Implementation
return course;
}
/**
* Create a new course
*/
@Post()
public async createCourse(
@Body() request: CreateCourseRequest,
): Promise<Course> {
// Implementation
return newCourse;
}
}Key Points:
- TSOA decorators (
@Route,@Get,@Post,@Path,@Body) define OpenAPI spec - Return types determine response shape in generated types
- Request body types define expected payload structure
- JSDoc comments become OpenAPI descriptions
Step 3: Generate OpenAPI Specification
TSOA automatically generates an OpenAPI spec from the controllers.
Configuration: apps/client-backend/tsoa.json
{
"entryFile": "src/index.ts",
"noImplicitAdditionalProperties": "throw-on-extras",
"spec": {
"outputDirectory": "lib",
"specVersion": 3
},
"routes": {
"routesDir": "lib"
}
}Generated: apps/client-backend/lib/swagger.json
{
"openapi": "3.0.0",
"paths": {
"/api/courses": {
"get": {
"operationId": "GetCourses",
"responses": {
"200": {
"description": "Ok",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": { "$ref": "#/components/schemas/Course" }
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"Course": {
"properties": {
"id": { "type": "number" },
"name": { "type": "string" },
"code": { "type": "string" },
"description": { "type": "string", "nullable": true }
},
"required": ["id", "name", "code"]
}
}
}
}Generation Trigger:
# Automatic in dev mode (watches for changes)
cd apps/client-backend
pnpm dev:api
# Manual generation
bun run src/generate.tsStep 4: Generate TypeScript Types
The @repo/api package watches the OpenAPI spec and generates TypeScript types.
File: packages/api/src/generate.ts
The generation script:
- Reads
swagger.jsonfrom the backend - Uses
openapi-typescriptto generate types - Applies custom transformers:
- Support for file uploads (File/Blob types)
- Inlines single-member enums as literal types
- Handles enum extraction
Generated: packages/api/lib/api.gen.ts
export interface paths {
"/api/courses": {
/** Get all courses */
get: {
responses: {
200: {
content: {
"application/json": components["schemas"]["Course"][];
};
};
};
};
};
"/api/courses/{id}": {
/** Get a single course by ID */
get: {
parameters: {
path: {
id: number;
};
};
responses: {
200: {
content: {
"application/json": components["schemas"]["Course"];
};
};
};
};
};
}
export interface components {
schemas: {
Course: {
id: number;
name: string;
code: string;
description: string | null;
modules?: components["schemas"]["Module"][];
};
};
}Key Features:
- Preserves nullable types
- Generates path parameters
- Includes request/response types
- Maintains JSDoc comments
Generation Trigger:
# Automatic in dev mode (watches swagger.json)
cd packages/api
pnpm dev
# Manual generation
tsx src/generate.tsStep 5: Create Type-Safe API Client
The API client uses the generated types with openapi-fetch.
File: packages/api/src/client.ts
import createClient from "openapi-fetch";
import type { paths } from "../lib/api.gen";
export const apiClient = createClient<paths>({
baseUrl: import.meta.env.VITE_API_URL || "http://localhost:3000",
headers: {
"Content-Type": "application/json",
},
});
// Helper to add auth token
export const setAuthToken = (token: string) => {
apiClient.use({
onRequest: async ({ request }) => {
request.headers.set("Authorization", `Bearer ${token}`);
return request;
},
});
};Usage:
// GET request - fully typed!
const { data, error } = await apiClient.GET("/api/courses");
// data: Course[] | undefined
// error: ErrorResponse | undefined
// GET with path parameter - type-checked!
const { data, error } = await apiClient.GET("/api/courses/{id}", {
params: {
path: { id: 123 }, // TypeScript ensures 'id' is a number
},
});
// data: Course | undefined
// POST request - body type-checked!
const { data, error } = await apiClient.POST("/api/courses", {
body: {
name: "New Course",
code: "CS101",
description: "A great course",
},
});
// TypeScript validates the body structureType Safety Benefits:
- URL paths are type-checked (no typos!)
- Path parameters are validated
- Request bodies must match expected shape
- Response types are inferred
- Query parameters are type-safe
Step 6: Use in React Components
Frontend components use the type-safe API client with TanStack Query.
File: apps/client-frontend/src/queries/courses.ts
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiClient } from "@repo/api";
import { queryClient } from "../queryClient";
// Query hook with full type safety
export function useCourses() {
return useQuery({
queryKey: ["courses"],
queryFn: async () => {
const { data, error } = await apiClient.GET("/api/courses");
if (error) throw error;
return data; // Type: Course[]
},
});
}
export function useCourse(id: number) {
return useQuery({
queryKey: ["courses", id],
queryFn: async () => {
const { data, error } = await apiClient.GET("/api/courses/{id}", {
params: { path: { id } },
});
if (error) throw error;
return data; // Type: Course
},
});
}
// Mutation hook
export function useCreateCourse() {
return useMutation({
mutationFn: async (course: {
name: string;
code: string;
description?: string;
}) => {
const { data, error } = await apiClient.POST("/api/courses", {
body: course,
});
if (error) throw error;
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["courses"] });
},
});
}Component Usage:
import { useCourses, useCreateCourse } from '../queries/courses';
export function CourseList() {
const { data: courses, isLoading, error } = useCourses();
const createCourse = useCreateCourse();
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
{courses?.map(course => (
<div key={course.id}>
{/* TypeScript knows all Course properties! */}
<h3>{course.name}</h3>
<p>{course.code}</p>
<p>{course.description ?? 'No description'}</p>
</div>
))}
<button
onClick={() =>
createCourse.mutate({
name: 'New Course',
code: 'CS102',
})
}
>
Create Course
</button>
</div>
);
}Type Safety in Action:
coursesis correctly typed asCourse[] | undefined- All course properties have IntelliSense
- Null checks are enforced by TypeScript
- Mutation parameters are type-checked
Benefits
1. Compile-Time Safety
// ❌ TypeScript error: URL doesn't exist
apiClient.GET("/api/wrong-url");
// ❌ TypeScript error: Missing required parameter
apiClient.GET("/api/courses/{id}");
// ❌ TypeScript error: Wrong parameter type
apiClient.GET("/api/courses/{id}", {
params: { path: { id: "123" } }, // Should be number, not string
});
// ❌ TypeScript error: Invalid body shape
apiClient.POST("/api/courses", {
body: { wrongField: "value" },
});2. Refactoring Safety
When you change a backend type:
- Update entity or controller
- Types regenerate automatically
- Frontend shows compile errors where changes affect usage
- Fix errors before running code
3. Developer Experience
- Auto-complete: Full IntelliSense for API calls
- Documentation: JSDoc comments flow through
- Type inference: Response types automatically inferred
- Validation: Catches errors at compile time
4. API Contract Enforcement
- Frontend and backend must agree on types
- No runtime surprises from API changes
- OpenAPI spec serves as single source of truth
- Can generate API documentation from spec
Custom Type Transformations
The system includes custom transformers for specific use cases:
File Upload Support
Transforms binary formats to File or Blob types:
// In OpenAPI spec
{
"type": "string",
"format": "binary"
}
// Generated TypeScript type
File | BlobEnum Handling
Inlines single-member enums as literal types:
// Instead of generating:
enum Status {
ACTIVE = "ACTIVE",
}
// Generates:
type Status = "ACTIVE";Nullable Types
Preserves nullable information:
// OpenAPI: { "type": "string", "nullable": true }
// TypeScript: string | nullWatching and Auto-Generation
Development Mode
When running pnpm dev from root:
- Backend watches controllers and regenerates OpenAPI spec
- API package watches OpenAPI spec and regenerates types
- Frontend uses updated types immediately
File Watchers
Backend: apps/client-backend/src/generate.ts
const watcher = watch(import.meta.dir, { recursive: true }, generate);API Package: packages/api/src/generate.ts
fs.watch(srcFile, { persistent: true })
.on("add", handler)
.on("change", handler);Troubleshooting
Types Not Updating
- Check if watchers are running:
# Terminal 1: Backend generation
cd apps/client-backend
pnpm dev:api
# Terminal 2: API generation
cd packages/api
pnpm dev- Manual regeneration:
cd apps/client-backend
bun run src/generate.ts
cd packages/api
tsx src/generate.tsType Mismatches
- Check
swagger.jsonmatches controller definitions - Clear generated files and regenerate
- Restart TypeScript server in IDE
Complex Types
For complex types not well-represented in OpenAPI:
- Define shared types in
@repo/utils - Use type assertions sparingly
- Document type transformations
Best Practices
- Always use TSOA decorators in controllers
- Never use
anyin controller return types - Define request/response interfaces explicitly
- Keep entities simple - avoid circular dependencies
- Document with JSDoc - comments flow to OpenAPI
- Test type generation after significant changes
- Use strict TypeScript settings
- Validate at runtime - types don't guarantee runtime data shape
Related Documentation
- Backend Components - Controller patterns
- Frontend Components - API usage patterns
- API Package - API client details
- Database Package - Entity definitions