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 run dev:api

# Manual generation
pnpm run dev:api

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
pnpm run dev:api

cd packages/api
pnpm run dev

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