TypeScript Type Generation Guide
This guide explains how to generate TypeScript types from the Rails backend using the OpenAPI-first approach, enabling type-safe API integration in the React frontend.
Overview
The type generation system uses an OpenAPI-first approach:
- Rails Backend generates an OpenAPI 3.0 specification from routes, blueprints, and forms
- Frontend uses
openapi-typescriptto generate TypeScript types from the spec
Rails Backend Frontend
| |
| rake oas:generate |
v |
[openapi.json] -----------------------> pnpm generate:types
| |
| v
| [api-types.ts]
| |
v v
API Documentation (Redoc/Swagger) Type-safe React codeQuick Start
Generate Everything
# From frontend directory - generates OAS spec + TypeScript types
cd frontend
pnpm generate:allGenerate OpenAPI Spec Only (Backend)
cd backend
bundle exec rake oas:generateOutput:
OpenAPI specification generated!
Output: backend/public/openapi.json
Domains: identity, billing, clinical, growth, connect, reference
Paths: 352
Schemas: 296Generate TypeScript Types Only (Frontend)
cd frontend
pnpm generate:typesWhat's Generated
OpenAPI Specification
The OAS generator (rake oas:generate) produces a comprehensive OpenAPI 3.0 spec including:
| Element | Source | Description |
|---|---|---|
| Endpoints | routes.rb | All API paths with HTTP methods |
| Request params | Routes + Controllers | Query, path, and body parameters |
| Request body schemas | *_form.rb files | Validation rules as JSON schemas |
| Response schemas | *_blueprint.rb files | Response shapes with view inheritance |
| Error schemas | Standard format | { error: { code, message, details } } |
| Authentication | JWT Bearer | Security scheme with tenant context |
| Pagination | Standard params | page, per_page, sort, order |
| Enum values | Model validations | Status values, country codes, etc. |
| Examples | Hardcoded | Sample request/response payloads |
| FHIR R4 | Identity routes | Healthcare interoperability endpoints |
| Rate limiting | Headers | X-RateLimit-* header definitions |
| File upload | Multipart | multipart/form-data support |
TypeScript Types
The openapi-typescript tool generates:
- Path types: Request/response types for each endpoint
- Component schemas: Reusable type definitions
- Enum types: Union types from OpenAPI enums
- Operations: Typed function signatures
// Generated enums as union types
SessionStatus: "ACTIVE" | "REVOKED" | "EXPIRED";
CountryCode: "IN" | "US" | "AE";
Gender: "male" | "female" | "other" | "unknown";
// Generated schemas with inheritance
Facility: { id: string; name: string; ... }
FacilityDetailed: components["schemas"]["Facility"] & { ... }
// Pagination meta
PaginationMeta: {
pagination?: {
total_entries: number;
total_pages: number;
current_page: number;
per_page: number;
}
}File Locations
backend/
public/
openapi.json # Generated OpenAPI spec
frontend/
packages/
api-client/
src/
generated/
api-types.ts # Generated TypeScript types (20k+ lines)Usage in React
Import Types
import type { components, paths, operations } from '@kulcare/api-client/generated/api-types';
// Access component schemas
type User = components['schemas']['User'];
type UserDetailed = components['schemas']['UserDetailed'];
type Facility = components['schemas']['Facility'];
type PaginationMeta = components['schemas']['PaginationMeta'];
// Access enum types
type SessionStatus = components['schemas']['SessionStatus'];
type CountryCode = components['schemas']['CountryCode'];Type-Safe API Responses
import type { components } from '@kulcare/api-client/generated/api-types';
type Patient = components['schemas']['Patient'];
type PatientDetailed = components['schemas']['PatientDetailed'];
type PaginationMeta = components['schemas']['PaginationMeta'];
interface PatientListResponse {
data: Patient[];
meta: PaginationMeta;
}
interface PatientResponse {
data: PatientDetailed;
}
async function fetchPatients(page = 1): Promise<PatientListResponse> {
const response = await fetch(`/api/v2/clinical/patients?page=${page}`);
return response.json();
}
async function fetchPatient(id: string): Promise<PatientResponse> {
const response = await fetch(`/api/v2/clinical/patients/${id}`);
return response.json();
}With React Query
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { components } from '@kulcare/api-client/generated/api-types';
type Invoice = components['schemas']['Invoice'];
type InvoiceDetailed = components['schemas']['InvoiceDetailed'];
export function useInvoices(params?: { page?: number; status?: string }) {
return useQuery({
queryKey: ['invoices', params],
queryFn: async () => {
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', String(params.page));
if (params?.status) searchParams.set('status', params.status);
const response = await fetch(`/api/v2/billing/invoices?${searchParams}`);
return response.json() as Promise<{ data: Invoice[]; meta: components['schemas']['PaginationMeta'] }>;
},
});
}
export function useInvoice(id: string) {
return useQuery({
queryKey: ['invoice', id],
queryFn: async () => {
const response = await fetch(`/api/v2/billing/invoices/${id}`);
return response.json() as Promise<{ data: InvoiceDetailed }>;
},
enabled: !!id,
});
}Type Guards
import type { components } from '@kulcare/api-client/generated/api-types';
type User = components['schemas']['User'];
type UserDetailed = components['schemas']['UserDetailed'];
function isUserDetailed(user: User | UserDetailed): user is UserDetailed {
return 'phone_number' in user;
}API Response Format
The Kulcare API uses a consistent response format:
Single Resource
{
"data": {
"id": "uuid",
"name": "Example",
"created_at": "2024-01-15T10:30:00Z"
}
}Collection with Pagination
{
"data": [
{ "id": "uuid-1", "name": "Example 1" },
{ "id": "uuid-2", "name": "Example 2" }
],
"meta": {
"pagination": {
"total_entries": 42,
"total_pages": 2,
"current_page": 1,
"per_page": 25
}
}
}Error Response
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": {
"errors": ["Email is invalid", "Password is too short"]
}
}
}FHIR R4 Support
The API includes FHIR R4-compatible endpoints for healthcare interoperability:
import type { components } from '@kulcare/api-client/generated/api-types';
type FhirBundle = components['schemas']['FhirBundle'];
type FhirOperationOutcome = components['schemas']['FhirOperationOutcome'];
// Search FHIR resources
const response = await fetch('/api/v2/identity/fhir/Patient?_count=20');
const bundle: FhirBundle = await response.json();
// Supported resource types
const FHIR_RESOURCES = [
'Patient', 'Practitioner', 'PractitionerRole', 'Organization',
'Location', 'HealthcareService', 'Encounter', 'CarePlan',
'Condition', 'Observation', 'MedicationRequest', 'DiagnosticReport',
'AllergyIntolerance', 'ServiceRequest', 'Goal', 'EpisodeOfCare'
];Rate Limiting
API responses include rate limit headers:
interface RateLimitHeaders {
'X-RateLimit-Limit': number; // Max requests per window
'X-RateLimit-Remaining': number; // Remaining requests
'X-RateLimit-Reset': number; // Unix timestamp when window resets
'X-Request-Id': string; // Request ID for debugging
}
function checkRateLimit(headers: Headers) {
const remaining = parseInt(headers.get('X-RateLimit-Remaining') || '0', 10);
const reset = parseInt(headers.get('X-RateLimit-Reset') || '0', 10);
if (remaining < 10) {
console.warn(`Rate limit low: ${remaining} requests remaining until ${new Date(reset * 1000)}`);
}
}How the OAS Generator Works
The generator (backend/lib/tasks/oas.rake) parses Rails source files:
Blueprint Parsing
Extracts response schemas from Blueprinter blueprints:
# Blueprint source
class UserBlueprint < Blueprinter::Base
view :normal do
fields :id, :email, :first_name, :last_name
end
view :detailed do
include_view :normal
fields :phone_number, :avatar_url, :timezone
end
endGenerates:
{
"User": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"email": { "type": "string", "format": "email" },
"first_name": { "type": "string" },
"last_name": { "type": "string" }
}
},
"UserDetailed": {
"allOf": [
{ "$ref": "#/components/schemas/User" },
{
"properties": {
"phone_number": { "type": "string" },
"avatar_url": { "type": "string", "format": "uri" },
"timezone": { "type": "string" }
}
}
]
}
}Form Parsing
Extracts request schemas from form objects:
# Form source
class UserSignUpForm
include ActiveModel::Model
attr_accessor :email, :password, :first_name, :last_name
attr_accessor :country_code, :phone_number
validates :email, presence: true
validates :password, presence: true, length: { minimum: 8 }
endRoute Parsing
Extracts endpoints from Rails routes:
# Routes source
namespace :identity do
resources :users, only: [:index, :show, :create, :update, :destroy] do
member do
post :verify_email
post :deactivate
end
end
endGenerates paths:
GET /identity/users- list usersGET /identity/users/{id}- get userPOST /identity/users- create userPATCH /identity/users/{id}- update userDELETE /identity/users/{id}- delete userPOST /identity/users/{id}/verify_email- verify emailPOST /identity/users/{id}/deactivate- deactivate user
Type Inference
Field types are inferred from naming patterns:
| Pattern | OpenAPI Type | TypeScript Type |
|---|---|---|
id, *_id | string, format: uuid | string |
*_ids | array of string | string[] |
email | string, format: email | string |
*_url | string, format: uri | string |
is_*, has_*, *_verified | boolean | boolean |
*_count, *_amount, total* | number | number |
*_at | string, format: date-time | string |
*_date, *_on | string, format: date | string |
metadata, settings | object | Record<string, unknown> |
Enum Values
Known enums are defined in the generator:
ENUM_VALUES = {
'status' => %w[active inactive pending suspended],
'session_status' => %w[ACTIVE REVOKED EXPIRED],
'two_factor_method' => %w[TOTP SMS EMAIL],
'country_code' => %w[IN US AE],
'gender' => %w[male female other unknown],
'invoice_status' => %w[draft issued paid partially_paid cancelled],
# ...
}CI/CD Integration
GitHub Actions
name: Generate API Types
on:
push:
paths:
- 'backend/domains/**/blueprints/**/*.rb'
- 'backend/domains/**/forms/**/*.rb'
- 'backend/domains/**/routes.rb'
jobs:
generate-types:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
working-directory: backend
bundler-cache: true
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install pnpm
run: npm install -g pnpm
- name: Install dependencies
run: cd frontend && pnpm install
- name: Generate OpenAPI spec
run: cd backend && bundle exec rake oas:generate
- name: Generate TypeScript types
run: cd frontend && pnpm generate:types
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "chore: regenerate API types"
file_pattern: |
backend/public/openapi.json
frontend/packages/api-client/src/generated/api-types.tsPre-commit Hook
Add to .husky/pre-commit:
#!/bin/sh
# Regenerate types if backend sources changed
if git diff --cached --name-only | grep -qE "blueprints/.*\.rb$|forms/.*\.rb$|routes\.rb$"; then
echo "Backend sources changed, regenerating API types..."
cd backend && bundle exec rake oas:generate
cd ../frontend && pnpm generate:types
git add backend/public/openapi.json
git add frontend/packages/api-client/src/generated/api-types.ts
fiAPI Documentation
The generated OpenAPI spec can be used with documentation tools:
Redoc
# Install redoc-cli
npm install -g @redocly/cli
# Preview documentation
redocly preview-docs backend/public/openapi.json
# Build static HTML
redocly build-docs backend/public/openapi.json -o docs/api.htmlSwagger UI
# Using Docker
docker run -p 8080:8080 \
-e SWAGGER_JSON=/api/openapi.json \
-v $(pwd)/backend/public:/api \
swaggerapi/swagger-uiTroubleshooting
Types Not Updating
- Ensure you run
rake oas:generatefrom thebackenddirectory - Check that the OpenAPI spec was updated:
ls -la backend/public/openapi.json - Run
pnpm generate:typesfrom thefrontenddirectory - Verify the types file was updated:
wc -l frontend/packages/api-client/src/generated/api-types.ts
Missing Schemas
The generator parses blueprint files with regex. Ensure they follow standard patterns:
# Supported patterns
view :normal do
fields :id, :email, :name
field :custom_field
field :computed do |obj| ... end
association :items, blueprint: ItemBlueprint
end
view :detailed do
include_view :normal
fields :additional_field
endIncorrect Field Types
Override type inference by adding patterns to TYPE_MAPPINGS in oas.rake:
TYPE_MAPPINGS = {
'latitude' => { type: 'number', format: 'double' },
'longitude' => { type: 'number', format: 'double' },
'coordinates' => { type: 'array', items: { type: 'number' } },
}Missing Enum Values
Add new enums to ENUM_VALUES in oas.rake:
ENUM_VALUES = {
# Add new enums
'appointment_status' => %w[scheduled confirmed checked_in completed cancelled no_show],
'prescription_status' => %w[draft active completed discontinued],
}TypeScript Compilation Errors
If the generated types have compilation errors:
# Check TypeScript compilation
cd frontend/packages/api-client
pnpm typecheck
# If errors, check the OpenAPI spec for issues
cd backend
cat public/openapi.json | jq '.components.schemas | keys'Commands Reference
# Generate OpenAPI spec (backend)
cd backend && bundle exec rake oas:generate
# Generate TypeScript types (frontend)
cd frontend && pnpm generate:types
# Generate both in sequence (from frontend)
cd frontend && pnpm generate:all
# Check generated spec
cat backend/public/openapi.json | jq '.info'
cat backend/public/openapi.json | jq '.paths | keys | length'
cat backend/public/openapi.json | jq '.components.schemas | keys | length'
# Check generated types
wc -l frontend/packages/api-client/src/generated/api-types.ts
grep -c "export interface\|export type" frontend/packages/api-client/src/generated/api-types.tsRelated Documentation
- API Response Guidelines - Response format standards
- Developer Guide - General development guide
- Testing Guide - Testing with generated types
Legacy: Rake-Based Generation (Deprecated)
The old Rake-based generators are still available but deprecated:
# These still work but are not recommended
bundle exec rake typescript:generate # Generate from blueprints directly
bundle exec rake api:generate # Generate API client from routes
bundle exec rake requests:generate # Generate request typesThe OpenAPI-first approach is preferred because:
- Single source of truth: The OpenAPI spec documents the actual API
- API documentation: Spec can be used with Redoc/Swagger
- Accuracy: Types match actual runtime responses
- Standardization: Uses industry-standard tooling (
openapi-typescript)