Skip to content

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

typescript
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

typescript
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

json
{
  "entryFile": "src/index.ts",
  "noImplicitAdditionalProperties": "throw-on-extras",
  "spec": {
    "outputDirectory": "lib",
    "specVersion": 3
  },
  "routes": {
    "routesDir": "lib"
  }
}

Generated: apps/client-backend/lib/swagger.json

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:

bash
# Automatic in dev mode (watches for changes)
cd apps/client-backend
pnpm dev:api

# Manual generation
bun run src/generate.ts

Step 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:

  1. Reads swagger.json from the backend
  2. Uses openapi-typescript to generate types
  3. 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

typescript
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:

bash
# Automatic in dev mode (watches swagger.json)
cd packages/api
pnpm dev

# Manual generation
tsx src/generate.ts

Step 5: Create Type-Safe API Client

The API client uses the generated types with openapi-fetch.

File: packages/api/src/client.ts

typescript
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:

typescript
// 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 structure

Type 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

typescript
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:

typescript
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:

  • courses is correctly typed as Course[] | undefined
  • All course properties have IntelliSense
  • Null checks are enforced by TypeScript
  • Mutation parameters are type-checked

Benefits

1. Compile-Time Safety

typescript
// ❌ 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:

  1. Update entity or controller
  2. Types regenerate automatically
  3. Frontend shows compile errors where changes affect usage
  4. 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:

typescript
// In OpenAPI spec
{
  "type": "string",
  "format": "binary"
}

// Generated TypeScript type
File | Blob

Enum Handling

Inlines single-member enums as literal types:

typescript
// Instead of generating:
enum Status {
  ACTIVE = "ACTIVE",
}

// Generates:
type Status = "ACTIVE";

Nullable Types

Preserves nullable information:

typescript
// OpenAPI: { "type": "string", "nullable": true }
// TypeScript: string | null

Watching and Auto-Generation

Development Mode

When running pnpm dev from root:

  1. Backend watches controllers and regenerates OpenAPI spec
  2. API package watches OpenAPI spec and regenerates types
  3. Frontend uses updated types immediately

File Watchers

Backend: apps/client-backend/src/generate.ts

typescript
const watcher = watch(import.meta.dir, { recursive: true }, generate);

API Package: packages/api/src/generate.ts

typescript
fs.watch(srcFile, { persistent: true })
  .on("add", handler)
  .on("change", handler);

Troubleshooting

Types Not Updating

  1. Check if watchers are running:
bash
# Terminal 1: Backend generation
cd apps/client-backend
pnpm dev:api

# Terminal 2: API generation
cd packages/api
pnpm dev
  1. Manual regeneration:
bash
cd apps/client-backend
bun run src/generate.ts

cd packages/api
tsx src/generate.ts

Type Mismatches

  1. Check swagger.json matches controller definitions
  2. Clear generated files and regenerate
  3. 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

  1. Always use TSOA decorators in controllers
  2. Never use any in controller return types
  3. Define request/response interfaces explicitly
  4. Keep entities simple - avoid circular dependencies
  5. Document with JSDoc - comments flow to OpenAPI
  6. Test type generation after significant changes
  7. Use strict TypeScript settings
  8. Validate at runtime - types don't guarantee runtime data shape