API Package
The @repo/api package provides auto-generated, type-safe API client code based on the backend's OpenAPI specification.
Overview
This package:
- Watches the backend's OpenAPI specification
- Generates TypeScript types using
openapi-typescript - Provides a type-safe API client using
openapi-fetch - Ensures frontend and backend type compatibility
Structure
packages/api/
├── src/
│ ├── client.ts # API client configuration
│ ├── enums.ts # Type-safe enum definitions
│ ├── generate.ts # Type generation script
│ ├── index.ts # Main exports
│ └── types.ts # Additional type utilities
│
├── lib/ # Generated files (git-ignored)
│ ├── api.gen.ts # Generated TypeScript types
│ └── enums.gen.ts # Generated enums
│
├── package.json
└── tsconfig.jsonGenerated Types
The package generates comprehensive types from the OpenAPI spec:
typescript
// lib/api.gen.ts (auto-generated)
export interface paths {
"/api/courses": {
get: {
responses: {
200: {
content: {
"application/json": components["schemas"]["Course"][];
};
};
};
};
post: {
requestBody: {
content: {
"application/json": components["schemas"]["CreateCourseRequest"];
};
};
responses: {
201: {
content: {
"application/json": components["schemas"]["Course"];
};
};
};
};
};
"/api/courses/{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"][];
};
CreateCourseRequest: {
name: string;
code: string;
description?: string;
};
};
}API Client
The package exports a configured openapi-fetch client:
typescript
// 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",
},
});
// Middleware for authentication
export function setAuthToken(token: string) {
apiClient.use({
onRequest: async ({ request }) => {
request.headers.set("Authorization", `Bearer ${token}`);
return request;
},
});
}
// Middleware for error handling
export function setupErrorHandling(onError: (error: Error) => void) {
apiClient.use({
onResponse: async ({ response }) => {
if (!response.ok) {
const error = await response.json();
onError(new Error(error.message || "Request failed"));
}
return response;
},
});
}Usage
Basic GET Request
typescript
import { apiClient } from "@repo/api";
// Fetch all courses
const { data, error } = await apiClient.GET("/api/courses");
if (error) {
console.error("Error fetching courses:", error);
} else {
// data is typed as Course[]
data.forEach((course) => {
console.log(course.name); // TypeScript knows 'name' exists
});
}GET with Path Parameters
typescript
const courseId = 123;
const { data, error } = await apiClient.GET("/api/courses/{id}", {
params: {
path: { id: courseId },
},
});
// data is typed as Course
if (data) {
console.log(data.name);
}GET with Query Parameters
typescript
const { data, error } = await apiClient.GET("/api/courses", {
params: {
query: {
search: "JavaScript",
limit: 10,
offset: 0,
},
},
});POST Request
typescript
const { data, error } = await apiClient.POST("/api/courses", {
body: {
name: "Introduction to TypeScript",
code: "TS101",
description: "Learn TypeScript fundamentals",
},
});
// TypeScript validates the body structure
// data is typed as CoursePUT Request
typescript
const { data, error } = await apiClient.PUT("/api/courses/{id}", {
params: {
path: { id: 123 },
},
body: {
name: "Updated Course Name",
},
});DELETE Request
typescript
const { error } = await apiClient.DELETE("/api/courses/{id}", {
params: {
path: { id: 123 },
},
});
if (!error) {
console.log("Course deleted successfully");
}File Upload
typescript
const file = new File(
[
/* file data */
],
"document.pdf",
);
const { data, error } = await apiClient.POST("/api/courses/{courseId}/files", {
params: {
path: { courseId: 123 },
},
body: {
file, // TypeScript knows this should be a File
title: "Course Document",
},
bodySerializer: (body) => {
const formData = new FormData();
formData.append("file", body.file);
formData.append("title", body.title);
return formData;
},
});Type Generation
Generation Script
typescript
// src/generate.ts
import fs from "node:fs";
import path from "node:path";
import openapiTS, { astToString } from "openapi-typescript";
const srcFile = path.resolve("../../apps/client-backend/lib/swagger.json");
const src = new URL(srcFile, import.meta.url);
async function generate() {
const ast = await openapiTS(src, {
alphabetize: true, // Sort for consistency
enum: true, // Generate enums
transform(schemaObject, options) {
// Custom transformers
// See Type Safety Flow documentation for details
},
});
const contents = astToString(ast);
fs.writeFileSync("lib/api.gen.ts", contents);
}
// Watch for changes
const watcher = fs.watch(srcFile, { persistent: true }).on("change", generate);
generate();Custom Transformers
The generator includes custom transformers for:
File Upload Support:
typescript
// Binary format → File/Blob type
{
"type": "string",
"format": "binary"
}
// Becomes: File | BlobEnum Handling:
typescript
// Single-member enums become literal types
enum Status {
ACTIVE = "ACTIVE",
}
// Becomes: type Status = "ACTIVE"Nullable Types:
typescript
// OpenAPI nullable preserved
{ "type": "string", "nullable": true }
// Becomes: string | nullEnums
Type-safe enums are re-exported from generated types:
typescript
// src/enums.ts
export {
UserRole as UserRoleEnum,
CourseStatus as CourseStatusEnum,
QuizQuestionType as QuizQuestionTypeEnum,
// ... other enums
} from "../lib/enums.gen";Usage:
typescript
import { UserRoleEnum, CourseStatusEnum } from "@repo/api";
const role: UserRoleEnum = "INSTRUCTOR";
const status: CourseStatusEnum = "PUBLISHED";Type Utilities
Additional type helpers:
typescript
// src/types.ts
import type { components } from "../lib/api.gen";
// Extract a specific schema type
export type Course = components["schemas"]["Course"];
export type User = components["schemas"]["User"];
export type Quiz = components["schemas"]["Quiz"];
// Extract response types
import type { paths } from "../lib/api.gen";
export type GetCoursesResponse =
paths["/api/courses"]["get"]["responses"][200]["content"]["application/json"];
export type CreateCourseRequest =
paths["/api/courses"]["post"]["requestBody"]["content"]["application/json"];
// Utility type for API errors
export interface ApiError {
timestamp: string;
path: string;
status: number;
message: string;
details?: Record<string, unknown>;
}Development Workflow
Watch Mode
During development, the package automatically regenerates types:
bash
cd packages/api
pnpm devThis watches apps/client-backend/lib/swagger.json and regenerates types on changes.
Manual Generation
Force type regeneration:
bash
cd packages/api
tsx src/generate.tsIntegration with Frontend
The frontend automatically gets updated types:
- Backend controller changes
- TSOA regenerates OpenAPI spec
- API package regenerates types
- Frontend sees new types (via monorepo linking)
- TypeScript shows errors for incompatible code
Error Handling
Typed Error Responses
typescript
const { data, error } = await apiClient.GET("/api/courses/{id}", {
params: { path: { id: 999 } },
});
if (error) {
// error contains the response data
console.error("Status:", error.status);
console.error("Message:", error.message);
// Handle specific errors
if (error.status === 404) {
console.log("Course not found");
} else if (error.status === 403) {
console.log("Access denied");
}
}Global Error Handler
typescript
// Setup once in your app
import { setupErrorHandling } from "@repo/api";
setupErrorHandling((error) => {
// Log to error tracking service
console.error("API Error:", error);
// Show toast notification
toast.error(error.message);
});Testing
Mocking API Responses
typescript
// In tests
import { apiClient } from "@repo/api";
// Mock the client
vi.mock("@repo/api", () => ({
apiClient: {
GET: vi.fn(),
POST: vi.fn(),
PUT: vi.fn(),
DELETE: vi.fn(),
},
}));
// Setup mock responses
apiClient.GET.mockResolvedValue({
data: [
{ id: 1, name: "Course 1", code: "CS101" },
{ id: 2, name: "Course 2", code: "CS102" },
],
error: undefined,
});Configuration
Environment Variables
env
# .env (frontend)
VITE_API_URL=http://localhost:3000Custom Base URL
typescript
import createClient from "openapi-fetch";
import type { paths } from "@repo/api";
const customClient = createClient<paths>({
baseUrl: "https://api.example.com",
});Best Practices
- Always check for errors before accessing data
- Use type utilities for cleaner code
- Set up auth middleware once at app initialization
- Handle errors globally with error middleware
- Don't modify generated files - they're overwritten
- Keep transformers in sync with backend requirements
- Test API integration with actual backend
Troubleshooting
Types Not Updating
bash
# Check if watcher is running
ps aux | grep tsx
# Restart generation
cd packages/api
pnpm dev
# Check OpenAPI spec exists
ls -la ../../apps/client-backend/lib/swagger.jsonType Errors After Backend Changes
- Ensure backend OpenAPI spec is regenerated
- Restart API package watcher
- Restart TypeScript server in IDE
- Clear build cache if needed
Circular Dependencies
If you encounter circular dependency issues:
- Define shared types in a separate package
- Use type-only imports:
import type { ... } - Avoid importing implementation from generated files
Related Documentation
- Type Safety Flow - Complete type flow documentation
- Backend Components - Backend API development
- Frontend Components - Using the API client