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
@kosServiceRequestdecorator, 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-projectThis command:
- Connects to the device at the configured host (or uses host from
project.json) - Downloads the complete OpenAPI specification
- Groups endpoints by app namespace (kos, dispense, freestyle, etc.)
- Generates versioned TypeScript types in
utils/services/{app}/{version}/ - Creates app-specific service clients with typed
kosServiceRequestdecorators - 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: fromproject.json)--default-version– Version for all apps (default: fromproject.jsonor 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 (
apilowercase) instead of genericAPI - SDK core services imported from published packages (e.g.,
KOS_SERVICEfrom@kosdev-code/kos-ui-sdk) - Custom application services imported from local
services-indexusing generated aliases (e.g.,ICE_APP,APC) - Versioned services accessed through unified export aliases for cleaner code
ApiPathtype annotation provides IntelliSense for valid endpoints in that app- Automatic typing from generated OpenAPI specs
- Per-app
kosServiceRequestdecorators 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:
- Explicit Error Handling: Forces developers to consider error cases
- Type Safety: TypeScript knows exactly what errors can occur
- Consistency: Same pattern everywhere, no try-catch needed
- Functional: Aligns with functional programming principles
- 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 numberParameter 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-projectThis 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
- Re-run the API generator regularly – Especially when device services are updated or new features are added
- Use app-specific, versioned imports – Import from
services/{app}/{version}/service - Use PATH_ constants for all endpoint definitions (no type annotation needed)
- Never use ‘any’ – preserve type safety throughout
- Handle both error and success cases explicitly
- Create service files per logical domain (ingredients, pumps, network, etc.)
- Use transformations to convert API responses to model-friendly formats
- Add logging for debugging and monitoring
- Test both paths – success and failure cases
- Version control awareness – Commit generated types when they change meaningfully
- 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
pathfor dynamic URLs,queryfor 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
@kosServiceRequestdecorator (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
