Kulcare Docs

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:

  1. Rails Backend generates an OpenAPI 3.0 specification from routes, blueprints, and forms
  2. Frontend uses openapi-typescript to 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 code

Quick Start

Generate Everything

# From frontend directory - generates OAS spec + TypeScript types
cd frontend
pnpm generate:all

Generate OpenAPI Spec Only (Backend)

cd backend
bundle exec rake oas:generate

Output:

OpenAPI specification generated!
   Output: backend/public/openapi.json
   Domains: identity, billing, clinical, growth, connect, reference
   Paths: 352
   Schemas: 296

Generate TypeScript Types Only (Frontend)

cd frontend
pnpm generate:types

What's Generated

OpenAPI Specification

The OAS generator (rake oas:generate) produces a comprehensive OpenAPI 3.0 spec including:

ElementSourceDescription
Endpointsroutes.rbAll API paths with HTTP methods
Request paramsRoutes + ControllersQuery, path, and body parameters
Request body schemas*_form.rb filesValidation rules as JSON schemas
Response schemas*_blueprint.rb filesResponse shapes with view inheritance
Error schemasStandard format{ error: { code, message, details } }
AuthenticationJWT BearerSecurity scheme with tenant context
PaginationStandard paramspage, per_page, sort, order
Enum valuesModel validationsStatus values, country codes, etc.
ExamplesHardcodedSample request/response payloads
FHIR R4Identity routesHealthcare interoperability endpoints
Rate limitingHeadersX-RateLimit-* header definitions
File uploadMultipartmultipart/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
end

Generates:

{
  "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 }
end

Route 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
end

Generates paths:

  • GET /identity/users - list users
  • GET /identity/users/{id} - get user
  • POST /identity/users - create user
  • PATCH /identity/users/{id} - update user
  • DELETE /identity/users/{id} - delete user
  • POST /identity/users/{id}/verify_email - verify email
  • POST /identity/users/{id}/deactivate - deactivate user

Type Inference

Field types are inferred from naming patterns:

PatternOpenAPI TypeTypeScript Type
id, *_idstring, format: uuidstring
*_idsarray of stringstring[]
emailstring, format: emailstring
*_urlstring, format: uristring
is_*, has_*, *_verifiedbooleanboolean
*_count, *_amount, total*numbernumber
*_atstring, format: date-timestring
*_date, *_onstring, format: datestring
metadata, settingsobjectRecord<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.ts

Pre-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
fi

API 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.html

Swagger UI

# Using Docker
docker run -p 8080:8080 \
  -e SWAGGER_JSON=/api/openapi.json \
  -v $(pwd)/backend/public:/api \
  swaggerapi/swagger-ui

Troubleshooting

Types Not Updating

  1. Ensure you run rake oas:generate from the backend directory
  2. Check that the OpenAPI spec was updated: ls -la backend/public/openapi.json
  3. Run pnpm generate:types from the frontend directory
  4. 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
end

Incorrect 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.ts

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 types

The OpenAPI-first approach is preferred because:

  1. Single source of truth: The OpenAPI spec documents the actual API
  2. API documentation: Spec can be used with Redoc/Swagger
  3. Accuracy: Types match actual runtime responses
  4. Standardization: Uses industry-standard tooling (openapi-typescript)

On this page