KOS Service Request Pattern – OpenAPI Integration and Type-Safe HTTP Communication

Overview

The KOS Service Request Pattern is a sophisticated approach to HTTP communication that combines OpenAPI specifications with TypeScript’s type system to create fully type-safe service layers. This pattern ensures compile-time safety, consistent error handling, and automatic code generation from live device APIs.

See Also: For declarative HTTP integration using the @kosServiceRequest decorator, see Declarative Service Requests. This document covers the imperative service layer pattern, while the decorator approach provides automatic lifecycle management and baseline-delta synchronization.

Key Benefits

  • Type Safety: Full TypeScript type inference from OpenAPI specifications
  • Error-First Tuples: Predictable error handling without try-catch proliferation
  • Auto-Generation: Pull types directly from running devices
  • Consistency: Enforced naming conventions and patterns across all services
  • Developer Experience: IntelliSense support for all API endpoints and payloads

Generating Type-Safe Services

Using the CLI Generator

KOS provides a CLI command to generate TypeScript types and service wrappers from device OpenAPI specifications:

kosui api:generate --project your-project

This command:

  1. Connects to the device at the configured host (or uses host from project.json)
  2. Downloads the complete OpenAPI specification
  3. Groups endpoints by app namespace (kos, dispense, freestyle, etc.)
  4. Generates versioned TypeScript types in utils/services/{app}/{version}/
  5. Creates app-specific service clients with typed kosServiceRequest decorators
  6. Produces aggregated index files for easy imports

Generated File Structure

For each app namespace, the generator creates versioned service files:

utils/services/{app}/{version}/openapi.d.ts – Type definitions for all endpoints in that app

utils/services/{app}/{version}/service.ts – App-specific service client with versioned decorators

utils/openapi-index.ts – Aggregated type exports across all apps

utils/services-index.ts – Aggregated service exports with configurable aliases

CLI Configuration

Configure generation in project.json:

{
  "targets": {
    "api": {
      "command": "kosui api:generate --project=your-project",
      "options": {
        "host": "http://127.0.0.1:8081",
        "defaultVersion": "snapshot",
        "appVersions": {
          "ice-app": "v2",
          "apc": "v3"
        }
      }
    }
  }
}

CLI arguments override project.json values:

  • --host – Device URL (default: from project.json)
  • --default-version – Version for all apps (default: from project.json or OpenAPI spec)
  • --all-apps-version – Force specific version for ALL apps
  • --app-version – Per-app version override (supports key=value format)

SDK-Provided Services vs Generated Services

Core services are published in SDK packages and don’t require generation:

  • kos – Core KOS device services (@kosdev-code/kos-ui-sdk)
  • vfs – Virtual file system services (@kosdev-code/kos-ui-sdk)
  • dispense – Beverage dispensing services (@kosdev-code/kos-dispense-sdk)
  • freestyle – Freestyle-specific services (@kosdev-code/kos-freestyle-sdk)
  • handle – Handle services (@kosdev-code/kos-ui-sdk)

For these services, import from published packages:

import { kosServiceRequest, type ApiResponse } from '@kosdev-code/kos-ui-sdk';
// or
import { type ApiResponse } from '@kosdev-code/kos-dispense-sdk';

Application-specific or custom services require generation using kosui api:generate:

  • Custom application APIs (ice-app, apc, etc.)
  • Device-specific extensions
  • Third-party integrations
  • App-specific endpoints

The generator automatically excludes core services (kos, vfs, dispense, freestyle, handle, kosdev.ddk).

Import Patterns: SDK vs Local Services

For SDK-provided core services (kos, vfs, dispense, freestyle):

// Import from published SDK packages
import { KOS_SERVICE } from '@kosdev-code/kos-ui-sdk';
import { DISPENSE_SERVICE } from '@kosdev-code/kos-dispense-sdk';
import { FREESTYLE_SERVICE } from '@kosdev-code/kos-freestyle-sdk';
const { api, type ApiPath, type ApiResponse } = KOS_SERVICE;

For custom/application-specific services:

// Import from local services-index using generated aliases
import { ICE_APP } from '../../../utils/services-index';
import { APC } from '../../../utils/services-index';
const { api, type ApiPath, type ApiResponse } = ICE_APP;

The services-index.ts file is auto-generated with configurable aliases for each app (configured via serviceExportAliases in project.json).

Creating Type-Safe Services

The Service Layer Pattern

Services in KOS follow a strict pattern that combines type safety with error-first tuples:

import { type ServiceResponse, KosLog, KOS_SERVICE } from '@kosdev-code/kos-ui-sdk';
// Access types and client from aliased export
const { api, type ApiPath, type ApiResponse } = KOS_SERVICE;
const log = KosLog.createLogger({
  name: 'ingredient-service',
  group: 'Services',
});
// Step 1: Define PATH constants (always use PATH_ prefix with ApiPath type for IntelliSense)
export const PATH_INGREDIENTS: ApiPath = '/api/kos/ingredients';
export const PATH_INGREDIENT: ApiPath = '/api/kos/ingredients/{id}';
// Step 2: Define response types using ApiResponse from aliased service
export type IngredientsResponse = ApiResponse<typeof PATH_INGREDIENTS, 'get'>;
export type IngredientResponse = ApiResponse<typeof PATH_INGREDIENT, 'get'>;
// Step 3: API methods already return ServiceResponse (error-first tuple)
export async function fetchIngredients(): Promise<ServiceResponse<IngredientsResponse>> {
  log.debug('Fetching ingredients');
  return await api.get(PATH_INGREDIENTS);
}
export async function fetchIngredientById(
  id: string
): Promise<ServiceResponse<IngredientResponse>> {
  log.debug(`Fetching ingredient ${id}`);
  return await api.get(PATH_INGREDIENT, {
    path: { id }  // Type-safe path parameters
  });
}

Key Changes from Previous Pattern

The new pattern uses app-specific, versioned imports:

Old approach (deprecated):

import API, { type ApiPath, type ApiResponse } from '../../../utils/service';
export const PATH_INGREDIENTS: ApiPath = '/api/kos/ingredients';
return await API.get(PATH_INGREDIENTS);

New approach (current):

import { KOS_SERVICE } from '@kosdev-code/kos-ui-sdk';
const { api, type ApiPath, type ApiResponse } = KOS_SERVICE;
export const PATH_INGREDIENTS: ApiPath = '/api/kos/ingredients';
return await api.get(PATH_INGREDIENTS);

Key differences:

  • App-specific clients (api lowercase) instead of generic API
  • SDK core services imported from published packages (e.g., KOS_SERVICE from @kosdev-code/kos-ui-sdk)
  • Custom application services imported from local services-index using generated aliases (e.g., ICE_APP, APC)
  • Versioned services accessed through unified export aliases for cleaner code
  • ApiPath type annotation provides IntelliSense for valid endpoints in that app
  • Automatic typing from generated OpenAPI specs
  • Per-app kosServiceRequest decorators with full type safety

Error-First Tuple Pattern

The ServiceResponse Type

KOS API methods return a ServiceResponse<T> type, which is an error-first tuple: [Error | null, Data | null]. This pattern provides several benefits:

  1. Explicit Error Handling: Forces developers to consider error cases
  2. Type Safety: TypeScript knows exactly what errors can occur
  3. Consistency: Same pattern everywhere, no try-catch needed
  4. Functional: Aligns with functional programming principles
  5. Testing: Easier to test both success and failure paths

Important: The API client methods (API.get, API.post, etc.) already return ServiceResponse – you don’t need to wrap them in try-catch blocks!

Using Error-First Tuples in Models

import { 
  kosModel, 
  KosModelType, 
  IKosDataModel,
  KosLog 
} from '@kosdev-code/kos-ui-sdk';
import { fetchIngredients, type IngredientsResponse } from './services/ingredient-service';
const log = KosLog.createLogger({
  name: 'IngredientManager',
  group: 'Models'
});
interface IngredientManagerOptions {
  refreshInterval?: number;
}
@kosModel()
class IngredientManager implements IKosDataModel {
  static instance(id: string) {
    return {
      options: (opts: IngredientManagerOptions) => ({
        build: () => new IngredientManager(id, opts)
      })
    };
  }
  modelType = KosModelType.Standard;
  // Properties are reactive by default in KOS models - no decorators needed
  ingredients: IngredientsResponse['ingredients'] = [];
  loading = false;
  error: Error | null = null;
  constructor(
    private id: string,
    private options: IngredientManagerOptions
  ) {
    log.debug(`IngredientManager created with id: ${id}`);
  }
  async loadIngredients(): Promise<void> {
    this.loading = true;
    this.error = null;
    // Using error-first tuple
    const [error, data] = await fetchIngredients();
    if (error) {
      log.error('Failed to load ingredients:', error);
      this.error = error;
      this.loading = false;
      return;
    }
    this.ingredients = data.ingredients;
    this.loading = false;
    log.debug(`Loaded ${this.ingredients.length} ingredients`);
  }
  async init(): Promise<void> {
    log.debug('IngredientManager initialized');
    // Load initial data
    await this.loadIngredients();
  }
  unload(): void {
    log.debug('IngredientManager unloading');
  }

Naming Conventions

PATH Constants

Always use the PATH_ prefix with ApiPath type annotation for API path constants:

// ✅ Correct - ApiPath type provides IntelliSense for valid endpoints
export const PATH_SERVICE: ApiPath = '/api/kos/service';
export const PATH_DEVICE_STATUS: ApiPath = '/api/kos/device/status';
export const PATH_PUMP_CALIBRATION: ApiPath = '/api/kos/pump/{id}/calibrate';
// ❌ Wrong - don't use different prefixes
export const SERVICE_PATH: ApiPath = '/api/kos/service';
export const DEVICE_STATUS: ApiPath = '/api/kos/device/status';
export const CALIBRATION_PATH: ApiPath = '/api/kos/pump/{id}/calibrate';
// ❌ Wrong - missing type annotation loses IntelliSense
export const PATH_SERVICE = '/api/kos/service';

Response Types

Use descriptive names ending with Response:

// ✅ Correct
export type DeviceStatusResponse = ApiResponse<typeof PATH_DEVICE_STATUS, 'get'>;
export type CalibrationResponse = ApiResponse<typeof PATH_PUMP_CALIBRATION, 'post'>;
// ❌ Wrong
export type DeviceStatus = ApiResponse<typeof PATH_DEVICE_STATUS, 'get'>;
export type CalibrationData = ApiResponse<typeof PATH_PUMP_CALIBRATION, 'post'>;

Advanced Patterns

Handling Path Parameters

For endpoints with path parameters, use the path option:

import { KOS_SERVICE } from '@kosdev-code/kos-ui-sdk';
const { api, type ApiPath, type ApiResponse } = KOS_SERVICE;
const PATH_NETWORK_NODE: ApiPath = '/api/network/set-connection/{nodeId}';
export type NetworkResponse = ApiResponse<typeof PATH_NETWORK_NODE, 'post'>;
export async function setNetworkConnection(
  nodeId: string,
  config: NetworkConfig
): Promise<ServiceResponse<NetworkResponse>> {
  return await api.post(PATH_NETWORK_NODE, {
    path: { nodeId }
  }, config);
}

Dynamic Parameters: Path and Query

The KOS Service Request Pattern provides full type safety for dynamic parameters through strongly-typed interfaces.

Path Parameters (Dynamic URL Segments)

For endpoints with path parameters like /api/kos/ingredients/{id} or /api/network/{nodeId}/status:

import { type ServiceResponse, KOS_SERVICE } from '@kosdev-code/kos-ui-sdk';
const { api, type ApiPath, type ApiResponse } = KOS_SERVICE;
// Endpoint with single path parameter
const PATH_INGREDIENT: ApiPath = '/api/kos/ingredients/{id}';
export type IngredientResponse = ApiResponse<typeof PATH_INGREDIENT, 'get'>;
export async function fetchIngredientById(
  id: string
): Promise<ServiceResponse<IngredientResponse>> {
  return await api.get(PATH_INGREDIENT, {
    path: { id }  // ✅ Strongly typed - TypeScript knows this endpoint requires 'id'
  });
}
// Endpoint with multiple path parameters
const PATH_PUMP_CONFIG = '/api/hardware/{boardId}/pump/{pumpId}/config';
export type PumpConfigResponse = ApiResponse<typeof PATH_PUMP_CONFIG, 'get'>;
export async function fetchPumpConfig(
  boardId: string,
  pumpId: string
): Promise<ServiceResponse<PumpConfigResponse>> {
  return await api.get(PATH_PUMP_CONFIG, {
    path: {
      boardId,   // ✅ TypeScript enforces both parameters are provided
      pumpId
    }
  });
}
// Complex path with nested resources
const PATH_SENSOR_READING = '/api/nodes/{nodeId}/sensors/{sensorType}/readings/{readingId}';
export async function fetchSensorReading(
  nodeId: string,
  sensorType: 'temperature' | 'pressure' | 'flow',
  readingId: string
): Promise<ServiceResponse<SensorReadingResponse>> {
  return await api.get(PATH_SENSOR_READING, {
    path: {
      nodeId,
      sensorType,     // ✅ Type-safe enum values
      readingId
    }
  });
}

Query Parameters (URL Search Parameters)

For endpoints that accept query parameters like ?limit=10&offset=20&filter=active:

const PATH_LOGS = '/api/kos/logs';
export type LogsResponse = ApiResponse<typeof PATH_LOGS, 'get'>;
export async function fetchLogs(
  limit: number,
  offset: number,
  level?: 'debug' | 'info' | 'warn' | 'error'
): Promise<ServiceResponse<LogsResponse>> {
  return await api.get(PATH_LOGS, {
    query: {
      limit,          // ✅ Required parameters
      offset,
      ...(level && { level })  // ✅ Optional parameters
    }
  });
}
// Complex query with multiple filters
const PATH_INGREDIENTS = '/api/kos/ingredients';
export async function fetchIngredients(
  options?: {
    status?: 'available' | 'unavailable' | 'expired';
    category?: string;
    search?: string;
    includeHidden?: boolean;
    sortBy?: 'name' | 'expirationDate' | 'status';
    sortOrder?: 'asc' | 'desc';
  }
): Promise<ServiceResponse<IngredientsResponse>> {
  return await api.get(PATH_INGREDIENTS, {
    query: {
      ...options  // ✅ Spread operator for optional query parameters
    }
  });
}

Combined Path and Query Parameters

Many endpoints use both path and query parameters:

const PATH_USER_ORDERS = '/api/users/{userId}/orders';
export async function fetchUserOrders(
  userId: string,
  filters?: {
    status?: 'pending' | 'completed' | 'cancelled';
    startDate?: string;
    endDate?: string;
    limit?: number;
    offset?: number;
  }
): Promise<ServiceResponse<UserOrdersResponse>> {
  return await api.get(PATH_USER_ORDERS, {
    path: { userId },           // ✅ Dynamic path parameter
    query: { ...filters }       // ✅ Optional query parameters
  });
}

POST/PUT with Parameters and Body

For endpoints that modify data, you can combine path parameters, query parameters, and request bodies.

IMPORTANT: The request body is passed as the third parameter, not within the options object.

const PATH_PUMP_CALIBRATION = '/api/hardware/{boardId}/pump/{pumpId}/calibrate';
export async function calibratePump(
  boardId: string,
  pumpId: string,
  calibrationData: {
    targetFlow: number;
    duration: number;
    testMode?: boolean;
  },
  options?: {
    dryRun?: boolean;
    validateOnly?: boolean;
  }
): Promise<ServiceResponse<CalibrationResponse>> {
  return await api.post(PATH_PUMP_CALIBRATION, {
    path: { boardId, pumpId },        // ✅ Path parameters
    query: { ...options }             // ✅ Query parameters
  }, calibrationData);                // ✅ Request body as third parameter
}
// Simple POST with just body (no path/query parameters)
const PATH_CREATE_USER = '/api/users';
export async function createUser(
  userData: {
    name: string;
    email: string;
    role: 'admin' | 'user';
  }
): Promise<ServiceResponse<UserResponse>> {
  return await api.post(PATH_CREATE_USER, {}, userData);  // ✅ Empty options, body as third param
}
// PUT with path parameter and body
const PATH_UPDATE_USER = '/api/users/{userId}';
export async function updateUser(
  userId: string,
  updates: Partial<{
    name: string;
    email: string;
    role: 'admin' | 'user';
  }>
): Promise<ServiceResponse<UserResponse>> {
  return await api.put(PATH_UPDATE_USER, {
    path: { userId }                  // ✅ Path parameter
  }, updates);                        // ✅ Body as third parameter
}

Complete API Method Signatures

The KOS API client methods have the following signatures:

// GET/DELETE methods
api.get(path, options?, kosFetchOptions?)
api.delete(path, options?, kosFetchOptions?)
// POST/PUT/PATCH methods
api.post(path, options?, body?, kosFetchOptions?)
api.put(path, options?, body?, kosFetchOptions?)
api.patch(path, options?, body?, kosFetchOptions?)

Body Parameter Patterns

// ✅ CORRECT - Body as third parameter
await api.post(PATH_ENDPOINT, {}, requestBody);
await api.post(PATH_ENDPOINT, { path: { id } }, requestBody);
await api.post(PATH_ENDPOINT, { query: { validate: true } }, requestBody);
await api.post(PATH_ENDPOINT, { path: { id }, query: { validate: true } }, requestBody);
// ❌ WRONG - Don't put body in options object
await api.post(PATH_ENDPOINT, { body: requestBody });
await api.post(PATH_ENDPOINT, { path: { id }, body: requestBody });

KosFetch Options Override

The final parameter allows overriding kosFetch options like destination address, timeouts, headers, etc:

// Override destination address to call different device
export async function fetchIngredients(
  targetDevice?: string
): Promise<ServiceResponse<IngredientsResponse>> {
  const kosFetchOptions = targetDevice ? {
    destinationAddress: targetDevice
  } : undefined;
  return await api.get(PATH_INGREDIENTS, {}, kosFetchOptions);
}
// Override timeout for long-running operations
export async function startCalibration(
  boardId: string,
  pumpId: string,
  calibrationData: CalibrationParams
): Promise<ServiceResponse<CalibrationResponse>> {
  return await api.post(PATH_PUMP_CALIBRATION, {
    path: { boardId, pumpId }
  }, calibrationData, {
    timeout: 30000  // 30 second timeout for calibration
  });
}
// Override headers for authentication or special operations
export async function secureOperation(
  operationData: OperationParams
): Promise<ServiceResponse<OperationResponse>> {
  return await api.post(PATH_SECURE_OPERATION, {}, operationData, {
    headers: {
      'Authorization': 'Bearer ' + getAuthToken(),
      'X-Device-Override': 'maintenance-mode'
    }
  });
}
// Ensure ordered delivery for critical operations
export async function criticalSystemUpdate(
  updateData: SystemUpdateParams
): Promise<ServiceResponse<UpdateResponse>> {
  return await api.put(PATH_SYSTEM_UPDATE, {}, updateData, {
    ordered: true,  // Ensure messages arrive in order
    timeout: 60000  // Allow extra time for critical updates
  });
}
// Multiple devices - calling different device instances
export async function fetchFromMultipleDevices(
  deviceAddresses: string[]
): Promise<Array<[Error | null, IngredientsResponse | null]>> {
  const promises = deviceAddresses.map(address =>
    api.get(PATH_INGREDIENTS, {}, {
      destinationAddress: address
    })
  );
  return await Promise.all(promises);
}

Type Safety Benefits

The OpenAPI integration ensures complete type safety:

// ✅ CORRECT - TypeScript validates all parameters
const [error, data] = await fetchPumpConfig('board-1', 'pump-S4');
// ❌ TypeScript Error - Missing required path parameter
const [error, data] = await api.get(PATH_PUMP_CONFIG, {
  path: { boardId: 'board-1' }  // Error: Property 'pumpId' is missing
});
// ❌ TypeScript Error - Wrong parameter name
const [error, data] = await api.get(PATH_INGREDIENT, {
  path: { ingredientId: '123' }  // Error: 'ingredientId' not assignable to 'id'
});
// ❌ TypeScript Error - Invalid query parameter type
const [error, data] = await fetchLogs('10', 20);  // Error: '10' not assignable to number

Parameter Validation Patterns

For robust services, validate parameters before making API calls:

export async function fetchIngredientById(
  id: string
): Promise<ServiceResponse<IngredientResponse>> {
  // Zone 3: Input validation
  if (!id || typeof id !== 'string') {
    return [new Error('Invalid ingredient ID provided'), null];
  }
  return await api.get(PATH_INGREDIENT, {
    path: { id: id.trim() }  // Clean and pass validated parameter
  });
}
export async function fetchLogs(
  limit: number,
  offset: number
): Promise<ServiceResponse<LogsResponse>> {
  // Zone 3: Parameter bounds checking
  const validatedLimit = Math.max(1, Math.min(limit, 1000));  // Clamp between 1-1000
  const validatedOffset = Math.max(0, offset);                // Ensure non-negative
  return await api.get(PATH_LOGS, {
    query: {
      limit: validatedLimit,
      offset: validatedOffset
    }
  });
}
### Transforming Response Data
When you need to transform API responses into model-friendly formats:
```typescript
// Zone 3: Pure transformation function
export function transformIngredientResponse(
  apiResponse: IngredientsResponse
): ModelIngredient[] {
  return apiResponse.ingredients.map(ingredient => ({
    id: ingredient.ingredient_id,
    name: ingredient.display_name || ingredient.name,
    available: ingredient.status === 'available',
    expirationDate: ingredient.expiration_timestamp 
      ? new Date(ingredient.expiration_timestamp)
      : null
  }));
}
// In your model (within a @kosModel decorated class)
async handleIngredientsLoaded(): Promise<void> {
  const [error, data] = await fetchIngredients();
  if (error) {
    this.handleError(error);
    return;
  }
  // Apply transformation
  this.ingredients = transformIngredientResponse(data);
}

Testing Services

Services with error-first tuples are easy to test:

import { fetchIngredients, PATH_INGREDIENTS } from './ingredient-service';
import { KOS_SERVICE } from '@kosdev-code/kos-ui-sdk';
const { api } = KOS_SERVICE;
jest.mock('@kosdev-code/kos-ui-sdk', () => ({
  ...jest.requireActual('@kosdev-code/kos-ui-sdk'),
  KOS_SERVICE: {
    api: {
      get: jest.fn()
    }
  }
}));
describe('Ingredient Service', () => {
  it('should return data on success', async () => {
    const mockData = { ingredients: [{ id: '1', name: 'Test' }] };
    (api.get as jest.Mock).mockResolvedValue([null, mockData]);
    const [error, data] = await fetchIngredients();
    expect(error).toBeNull();
    expect(data).toEqual(mockData);
    expect(api.get).toHaveBeenCalledWith(PATH_INGREDIENTS);
  });
  it('should return error on failure', async () => {
    const mockError = new Error('Network error');
    (api.get as jest.Mock).mockResolvedValue([mockError, null]);
    const [error, data] = await fetchIngredients();
    expect(error).toEqual(mockError);
    expect(data).toBeNull();
  });
});

Common Pitfalls and Solutions

Pitfall 1: Using ‘any’ for Response Types

// ❌ Wrong - loses type safety
export async function setLedDisplay(pixelData: any): Promise<ServiceResponse<any>> {
  return await api.post(PATH_LED_CANVAS, {}, pixelData);
}
// ✅ Correct - maintains type safety
export type LedCanvasResponse = ApiResponse<typeof PATH_LED_CANVAS, 'post'>;
export async function setLedDisplay(
  pixelData: LedPixelData
): Promise<ServiceResponse<LedCanvasResponse>> {
  return await api.post(PATH_LED_CANVAS, {}, pixelData);
}

Pitfall 2: Forgetting to Handle Errors

// ❌ Wrong - ignoring error case (within a @kosModel decorated class)
async loadData() {
  const [_, data] = await fetchIngredients();
  this.ingredients = data.ingredients; // Could crash if data is null
}
// ✅ Correct - handling both cases (within a @kosModel decorated class)
async loadData() {
  const [error, data] = await fetchIngredients();
  if (error) {
    this.handleError(error);
    return;
  }
  this.ingredients = data.ingredients;
}

Pitfall 3: Missing Path Parameters

// ❌ Wrong - missing required path parameters
await api.get('/api/ingredients/{id}', {});  // Missing 'path' property
// ✅ Correct - providing path parameters
await api.get('/api/ingredients/{id}', {
  path: { id: 'ingredient-123' }
});

Pitfall 4: Wrong Body Parameter Position

// ❌ Wrong - putting body in options object
await api.post(PATH_CREATE_USER, {
  body: { name: 'John', email: 'john@example.com' }
});
await api.put(PATH_UPDATE_USER, {
  path: { userId: '123' },
  body: { name: 'Jane' }
});
// ✅ Correct - body as third parameter
await api.post(PATH_CREATE_USER, {}, {
  name: 'John',
  email: 'john@example.com'
});
await api.put(PATH_UPDATE_USER, {
  path: { userId: '123' }
}, {
  name: 'Jane'
});

Migration Guide

If you have existing services using different patterns, here’s how to migrate:

From Promise/Throw to ServiceResponse

// Old pattern - throws errors
export async function fetchData(): Promise<DataResponse> {
  try {
    return await fetch('/api/data').then(r => r.json());
  } catch (error) {
    console.error('Error:', error);
    throw error;
  }
}
// New pattern - returns ServiceResponse (error-first tuple)
export const PATH_DATA = '/api/data';
export type DataResponse = ApiResponse<typeof PATH_DATA, 'get'>;
export async function fetchData(): Promise<ServiceResponse<DataResponse>> {
  log.debug('Fetching data');
  return await api.get(PATH_DATA);
}

From Manual Typing to ApiResponse

// Old pattern - manual interface definition
interface MyResponse {
  data: string[];
}
export async function fetchMyData(): Promise<MyResponse> {
  return await fetch('/api/mydata').then(r => r.json());
}
// New pattern - type-safe with ApiResponse
export const PATH_MY_DATA = '/api/mydata';
export type MyDataResponse = ApiResponse<typeof PATH_MY_DATA, 'get'>;
export async function fetchMyData(): Promise<ServiceResponse<MyDataResponse>> {
  return await api.get(PATH_MY_DATA);
}

Keeping Types in Sync

When to Re-run the API Generator

The OpenAPI specification is a living document that evolves as device capabilities expand. You must re-run the generator whenever:

  • New services are deployed to the device
  • Existing endpoints are modified (parameters, response types)
  • You switch between devices (simulator vs real device)
  • After device firmware updates
  • When developing new features that add device endpoints
  • API versions change (upgrading from v1 to v2)
# Always refresh types when device APIs change
kosui api:generate --project your-project

This ensures:

  • Maximum type safety – TypeScript knows about all available endpoints
  • Accurate IntelliSense – Auto-completion shows current API surface
  • Build-time detection – Compilation catches removed or changed endpoints
  • No runtime surprises – Types match actual device behavior

API Target Configuration

Configure generation in project.json for consistent builds:

{
  "targets": {
    "api": {
      "command": "kosui api:generate --project=your-project",
      "options": {
        "host": "http://127.0.0.1:8081",
        "defaultVersion": "snapshot",
        "appVersions": {
          "ice-app": "v2"
        }
      }
    },
    "build": {
      "dependsOn": ["api"],  // Automatically refresh types before building
      "executor": "@nx/js:tsc"
    }
  }
}

Best Practices

  1. Re-run the API generator regularly – Especially when device services are updated or new features are added
  2. Use app-specific, versioned imports – Import from services/{app}/{version}/service
  3. Use PATH_ constants for all endpoint definitions (no type annotation needed)
  4. Never use ‘any’ – preserve type safety throughout
  5. Handle both error and success cases explicitly
  6. Create service files per logical domain (ingredients, pumps, network, etc.)
  7. Use transformations to convert API responses to model-friendly formats
  8. Add logging for debugging and monitoring
  9. Test both paths – success and failure cases
  10. Version control awareness – Commit generated types when they change meaningfully
  11. Configure in project.json – Set host, versions, and aliases for consistent generation

Troubleshooting

“Cannot find module ‘./services/kos/v1/service'”

Run the API generator first: kosui api:generate --project your-project

“Type ‘paths’ has no property ‘/api/…'”

The endpoint might not exist in your device’s OpenAPI spec. Verify with:

curl http://127.0.0.1:8081/api/openapi | jq '.paths | keys'

“Property ‘id’ is missing in type”

Ensure all required path parameters are provided in the path object.

Type Inference Not Working

Ensure you’re using typeof PATH_CONSTANT in your ApiResponse type definition:

// ✅ Correct - uses typeof
export type MyResponse = ApiResponse<typeof PATH_SERVICE, 'get'>;
// ❌ Wrong - uses string literal
export type MyResponse = ApiResponse<'/api/service', 'get'>;

Complete Example: Putting It All Together

Here’s a complete, KOS-compliant example showing the entire pattern:

// services/device-service.ts
import { type ServiceResponse, KosLog, KOS_SERVICE } from '@kosdev-code/kos-ui-sdk';
const { api, type ApiPath, type ApiResponse } = KOS_SERVICE;
const log = KosLog.createLogger({
  name: 'device-service',
  group: 'Services'
});
export const PATH_DEVICE_STATUS: ApiPath = '/api/kos/device/status';
export type DeviceStatusResponse = ApiResponse<typeof PATH_DEVICE_STATUS, 'get'>;
export async function fetchDeviceStatus(): Promise<ServiceResponse<DeviceStatusResponse>> {
  log.debug('Fetching device status');
  return await api.get(PATH_DEVICE_STATUS);
}
// models/device-manager.ts
import {
  kosModel,
  KosModelType,
  IKosDataModel,
  KosLog,
  kosTopicHandler
} from '@kosdev-code/kos-ui-sdk';
import { fetchDeviceStatus, type DeviceStatusResponse } from './services/device-service';
const log = KosLog.createLogger({
  name: 'DeviceManager',
  group: 'Models'
});
// Define topic constants with TOPIC_ prefix
const TOPIC_DEVICE_STATUS_CHANGED = '/kos/device/status/changed';
interface DeviceManagerOptions {
  pollingInterval?: number;
}
@kosModel()
export class DeviceManager implements IKosDataModel {
  static instance(id: string) {
    return {
      options: (opts: DeviceManagerOptions) => ({
        build: () => new DeviceManager(id, opts)
      })
    };
  }
  modelType = KosModelType.Standard;
  // Properties are reactive by default - no decorators needed
  status: DeviceStatusResponse | null = null;
  loading = false;
  error: Error | null = null;
  constructor(
    private id: string,
    private options: DeviceManagerOptions
  ) {
    log.debug(`DeviceManager created with id: ${id}`);
  }
  async loadStatus(): Promise<void> {
    this.loading = true;
    this.error = null;
    const [error, data] = await fetchDeviceStatus();
    if (error) {
      log.error('Failed to load device status:', error);
      this.error = error;
      this.loading = false;
      return;
    }
    this.status = data;
    this.loading = false;
    log.debug('Device status loaded successfully');
  }
  @kosTopicHandler({ topic: TOPIC_DEVICE_STATUS_CHANGED })
  async handleDeviceStatusChanged(payload: any): Promise<void> {
    log.debug('Device status changed, reloading...');
    await this.loadStatus();
  }
  async init(): Promise<void> {
    log.debug('DeviceManager initialized');
    // Load initial status
    await this.loadStatus();
  }
  unload(): void {
    log.debug('DeviceManager unloading');
  }
}

Important Notes About KOS Models

Properties Are Reactive

In KOS models, all properties are automatically reactive. When you update a property value, any UI components using that model will automatically re-render with the new data. No special decorators or annotations are needed.

Lifecycle Methods

KOS models provide lifecycle methods for initialization and cleanup:

  • init(): Called when the model is initialized. Use for loading initial data.
  • unload(): Called when the model is being cleaned up. Use for cleanup tasks.

Topic Handler Pattern

// Define topic constants
const TOPIC_MY_EVENT = '/kos/my/event/path';
// Use in handler
@kosTopicHandler({ topic: TOPIC_MY_EVENT })
async handleMyEvent(payload: any): Promise<void> {
  // Handle the event
}

Service Response Pattern

The API client automatically returns ServiceResponse<T> (error-first tuple):

// ✅ Correct - api.get already returns ServiceResponse
export async function fetchData(): Promise<ServiceResponse<DataResponse>> {
  return await api.get(PATH_DATA);
}
// ❌ Wrong - unnecessary try-catch, API already handles errors
export async function fetchData(): Promise<[Error | null, DataResponse | null]> {
  try {
    const response = await api.get(PATH_DATA);
    return [null, response];
  } catch (error) {
    return [error as Error, null];
  }
}
// ❌ Wrong - losing type safety
export async function fetchData(): Promise<ServiceResponse<any>> {
  return await api.get(PATH_DATA);
}

Conclusion

The KOS Service Request Pattern provides a robust, type-safe foundation for HTTP communication in KOS applications. By combining OpenAPI specifications with TypeScript’s type system and error-first tuples, developers get compile-time safety, predictable error handling, and excellent developer experience.

Key takeaways:

  • Run the API target to pull types from devices
  • Use PATH_ constants for endpoint definitions
  • Return error-first tuples for predictable error handling
  • No MobX decorators – KOS handles reactivity internally
  • Use TOPIC_ constants for event subscriptions
  • Leverage type-safe parameters – Use path for dynamic URLs, query for search parameters
  • Validate parameters – Check inputs before API calls for robust error handling

Remember: Pull types from the device, write services with error-first tuples, and let TypeScript ensure correctness!

Next Steps

For more advanced patterns and declarative HTTP integration:

  • Declarative Service Requests – Learn about the @kosServiceRequest decorator (from versioned service files) for automatic lifecycle management, caching, and baseline-delta synchronization
  • KOS Decorator Pattern – Comprehensive guide to all KOS decorators including @kosServiceRequest (app-specific), @kosTopicHandler, and more