Skip to main content
Understand how Sonamu automatically generates type-safe client Services from backend APIs.

Auto-generated Service Overview

Type Safety

Synced with backendCompile-time validation

Namespace Based

Static functionsConcise calls

TanStack Query

Auto-generated React HooksCaching and revalidation

Subset Support

Only needed fieldsPerformance optimization

Why Auto-generation?

Problem: Limitations of Manual API Clients

In traditional frontend development, you manually write client code to call backend APIs. Manual client example:
// ❌ Manual writing - many problems
async function getUser(userId: number) {
  const response = await axios.get(`/api/user/${userId}`);
  return response.data;
}

async function updateUser(userId: number, data: any) {
  const response = await axios.put(`/api/user/${userId}`, data);
  return response.data;
}
Problems with this approach:
  1. Lack of type safety: Overuse of any type, runtime errors occur
  2. Cannot track backend changes: Frontend doesn’t know when API changes
  3. Duplicate code: Similar code repeated for every API
  4. Prone to mistakes: URL typos, wrong parameters, etc.
  5. Hard to maintain: Need to modify all call sites when API changes

Solution: Benefits of Auto-generation

Sonamu analyzes the @api decorator from the backend to automatically generate type-safe clients. Auto-generated client example:
// ✅ Auto-generated - type safe (Namespace based)
export namespace UserService {
  // Query only needed fields with Subset
  export async function getUser<T extends UserSubsetKey>(
    subset: T,
    id: number
  ): Promise<UserSubsetMapping[T]> {
    return fetch({
      method: "GET",
      url: `/api/user/findById?${qs.stringify({ subset, id })}`,
    });
  }
  
  export async function updateUser(
    id: number,
    params: { username?: string; email?: string }
  ): Promise<User> {
    return fetch({
      method: "PUT",
      url: `/api/user/update`,
      data: { id, ...params },
    });
  }
}
Benefits:
  1. Complete type safety: Backend types are directly reflected in frontend
  2. Immediate error detection: API changes detected at compile time
  3. Auto-completion: IDE automatically suggests API parameters and response types
  4. Namespace based: Clean structure, easy imports
  5. Single source of truth: Backend is the only source for API specs
Single Source of Truth is the principle where all information in the system derives from one source. In Sonamu, the @api decorator in the backend is the only API specification, and the frontend follows it.

Service Generation Process

Step 1: Backend API Definition

Define APIs with the @api decorator in the backend.
// backend/models/user.model.ts
import { BaseModelClass, api } from "sonamu";
import type { UserSubsetKey, UserSubsetMapping } from "../sonamu.generated";
import { userLoaderQueries, userSubsetQueries } from "../sonamu.generated.sso";

class UserModelClass extends BaseModelClass<
  UserSubsetKey,
  UserSubsetMapping,
  typeof userSubsetQueries,
  typeof userLoaderQueries
> {
  constructor() {
    super("User", userSubsetQueries, userLoaderQueries);
  }
  
  /**
   * Get user profile
   */
  @api({ httpMethod: "GET" })
  async getProfile(userId: number): Promise<{
    user: {
      id: number;
      email: string;
      username: string;
      createdAt: Date;
    };
  }> {
    const rdb = this.getPuri("r");
    const user = await rdb
      .table("users")
      .where("id", userId)
      .first();
    
    return { user };
  }
  
  /**
   * Update user profile
   */
  @api({ httpMethod: "PUT", guards: ["user"] })
  async updateProfile(params: {
    username?: string;
    bio?: string;
  }): Promise<{
    user: {
      id: number;
      username: string;
      bio: string;
    };
  }> {
    // Implementation...
  }
}
This API definition is the starting point for everything. Type information, parameters, and response format are all defined here.

Step 2: TypeScript AST Parsing

Sonamu uses the TypeScript compiler API to analyze the code. Analysis process:
// Sonamu internal operation (pseudo code)
const sourceFile = ts.createSourceFile(
  "user.model.ts",
  fileContent,
  ts.ScriptTarget.Latest
);

// Find methods with @api decorator
const apiMethods = findDecorators(sourceFile, "api");

for (const method of apiMethods) {
  const apiInfo = {
    name: method.name.text,                    // "getProfile"
    httpMethod: getDecoratorOption("httpMethod"), // "GET"
    path: `/api/user/${method.name.text}`,    // "/api/user/getProfile"
    parameters: extractParameters(method),     // [{ name: "userId", type: "number" }]
    returnType: extractReturnType(method),     // Promise<{ user: User }>
  };
  
  // Generate Service code
  generateServiceMethod(apiInfo);
}
Key concepts:
  • AST (Abstract Syntax Tree): Represents code as a tree structure
  • Type extraction: Obtains accurate type information from TypeScript’s type system
  • Metadata collection: Collects all information including decorator options, Guards, comments

Step 3: Namespace Service Generation

Based on collected information, Namespace-based Services are generated. Generated code (services.generated.ts):
import qs from "qs";

// Subset type definitions
export type UserSubsetKey = "A" | "B" | "C";

// Subset type mapping
export type UserSubsetMapping = {
  A: { id: number; email: string; username: string };           // Basic fields
  B: { id: number; email: string; username: string; bio: string }; // + bio
  C: User; // All fields (including createdAt, updatedAt, etc.)
};

/**
 * User Service Namespace
 * 
 * Namespace responsible for all User-related API calls.
 */
export namespace UserService {
  /**
   * Get user (with Subset support)
   * 
   * @param subset - Field range to query ("A" | "B" | "C")
   * @param id - User ID
   * @returns Type-safe user info according to Subset
   */
  export async function getUser<T extends UserSubsetKey>(
    subset: T,
    id: number
  ): Promise<UserSubsetMapping[T]> {
    // Serialize query parameters with qs.stringify
    return fetch({
      method: "GET",
      url: `/api/user/findById?${qs.stringify({ subset, id })}`,
    });
  }
  
  /**
   * Update user profile
   * 
   * @param params - Fields to update
   * @returns Updated user info
   */
  export async function updateProfile(params: {
    username?: string;
    bio?: string;
  }): Promise<{
    user: {
      id: number;
      username: string;
      bio: string;
    };
  }> {
    return fetch({
      method: "PUT",
      url: "/api/user/updateProfile",
      data: params, // POST/PUT sends as body
    });
  }
}
Benefits of Namespace structure:
  • Simplicity: Simpler than classes (no new required)
  • Static methods: No state management needed
  • Tree-shaking: Unused functions excluded from bundle
  • Easy import: import { UserService } from "./services.generated"

Step 4: TanStack Query Hook Generation

Hooks that can be used directly in React are also auto-generated.
// services.generated.ts (continued)
import { useQuery, queryOptions } from "@tanstack/react-query";

export namespace UserService {
  // ... functions above
  
  /**
   * TanStack Query Options
   * 
   * Reusable options including queryKey and queryFn.
   */
  export const getUserQueryOptions = <T extends UserSubsetKey>(
    subset: T,
    id: number
  ) =>
    queryOptions({
      queryKey: ["User", "getUser", subset, id],
      queryFn: () => getUser(subset, id),
    });
  
  /**
   * React Hook (TanStack Query)
   * 
   * Hook that provides auto caching, revalidation, and loading states.
   */
  export const useUser = <T extends UserSubsetKey>(
    subset: T,
    id: number,
    options?: { enabled?: boolean }
  ) =>
    useQuery({
      ...getUserQueryOptions(subset, id),
      ...options,
    });
}
Benefits of TanStack Query integration:
  • Auto caching
  • Auto revalidation
  • Auto loading/error state management
  • Conditional fetching support
  • Optimistic updates support

Structure of Generated Services

fetch Utility Function

Common fetch function used by all Services.
// sonamu.shared.ts
import axios, { AxiosRequestConfig } from "axios";
import { z } from "zod";

/**
 * Common fetch function
 * 
 * All API calls go through this function.
 * Wraps Axios to handle errors and transform responses.
 */
export async function fetch(options: AxiosRequestConfig) {
  try {
    const res = await axios({
      ...options,
    });
    return res.data;
  } catch (e: unknown) {
    // Convert Axios error to SonamuError
    if (axios.isAxiosError(e) && e.response && e.response.data) {
      const d = e.response.data as {
        message: string;
        issues: z.ZodIssue[];
      };
      throw new SonamuError(e.response.status, d.message, d.issues);
    }
    throw e;
  }
}

/**
 * Sonamu Error class
 * 
 * Includes HTTP status code and Zod validation issues.
 */
export class SonamuError extends Error {
  isSonamuError: boolean;

  constructor(
    public code: number,        // HTTP status code (401, 403, 422, etc.)
    public message: string,     // Error message
    public issues: z.ZodIssue[] // Zod validation issues
  ) {
    super(message);
    this.isSonamuError = true;
  }
}

/**
 * Error type guard
 */
export function isSonamuError(e: any): e is SonamuError {
  return e && e.isSonamuError === true;
}
Role of fetch function:
  • Wraps Axios calls: Passes options to Axios
  • Auto response extraction: Returns res.data directly
  • Error conversion: Axios error → SonamuError
  • Zod issue handling: Type-safe handling of validation errors
AxiosRequestConfig parameters:
{
  method: "GET" | "POST" | "PUT" | "DELETE",
  url: string,
  params?: Record<string, any>,  // GET query parameters
  data?: any,                     // POST/PUT body
  headers?: Record<string, string>,
}

Subset System

Sonamu’s unique Subset system. What is Subset? A system that defines multiple variants (subsets) of an entity to query only needed fields.
// Subset definition (auto-generated)
export type UserSubsetKey = "A" | "B" | "C";

export type UserSubsetMapping = {
  A: { id: number; email: string; username: string },           // Basic info
  B: { id: number; email: string; username: string; bio: string }, // + bio
  C: User, // All fields (including createdAt, updatedAt, deletedAt, etc.)
};

// Usage example
const basicUser = await UserService.getUser("A", 123);  
// Type: { id: number; email: string; username: string }

const fullUser = await UserService.getUser("C", 123);   
// Type: User (all fields)
Benefits of Subset:
  1. Performance: Query only needed fields to reduce network cost
  2. Type safety: Returns accurate type for each Subset
  3. Explicitness: Clearly express what data is needed in code
  4. Database optimization: Include only necessary columns in SELECT clause
Subset naming convention:
  • A: Basic fields (id, core info)
  • B: Intermediate fields (A + additional info)
  • C: All fields (all columns, including timestamps)

Type Safety in Practice

Compile-time Validation

When backend API changes, errors occur immediately at compile time. Backend change:
// Backend changes username -> displayName
@api({ httpMethod: "PUT" })
async updateProfile(params: {
  displayName?: string;  // Changed from username
  bio?: string;
}): Promise<{ user: User }> {
  // ...
}
Frontend error:
// After pnpm generate, Service types are auto-updated

// ❌ Compile error!
await UserService.updateProfile({
  username: "newname",  // Error: 'username' does not exist in type
});

// ✅ Works after fix
await UserService.updateProfile({
  displayName: "newname",  // OK
});
This brings runtime errors to compile time to prevent bugs in advance.

IDE Auto-completion

Thanks to type information, IDE provides powerful auto-completion.
// When typing...
UserService.up  // IDE suggests "updateProfile"

// When entering parameters...
await UserService.updateProfile({
  // IDE suggests all possible fields:
  // - displayName?: string
  // - bio?: string
});

// Subset also auto-completes
await UserService.getUser(
  "A" // IDE suggests "A" | "B" | "C"
  , 123
);

Development Workflow

Backend-First Development

Sonamu’s auto-generation encourages Backend-First development. Typical workflow: Benefits:
  • Clear contract between backend and frontend
  • Prevents bugs from type mismatch at source
  • Auto-generated API documentation (Service is the documentation)
  • Improved collaboration efficiency

Regeneration During Development

Services must be regenerated whenever API changes.
# Regenerate Services
pnpm generate

# Or auto-regenerate with watch mode
pnpm generate:watch
Cautions:
  • Never manually modify generated Service files (services.generated.ts)
  • If modifications needed, modify backend and regenerate
  • Team decides whether to add generated files to .gitignore
    • If added: Each person generates locally
    • If not added: Shared via Git (reduces build time)

Practical Usage Examples

Basic Usage

import { UserService } from "@/services/services.generated";

// Query only basic info with Subset "A"
const user = await UserService.getUser("A", 123);
console.log(user.username); // OK
console.log(user.bio); // ❌ Compile error (bio not in Subset A)

// Query including bio with Subset "B"
const userWithBio = await UserService.getUser("B", 123);
console.log(userWithBio.bio); // OK

// Update profile
await UserService.updateProfile({
  username: "newname",
  bio: "Hello, World!",
});

Usage in React (TanStack Query Hook)

import { UserService } from "@/services/services.generated";

function UserProfile({ userId }: { userId: number }) {
  // Use auto-generated Hook
  const { data: user, isLoading, error } = UserService.useUser("A", userId);
  
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!user) return <div>User not found</div>;
  
  return (
    <div>
      <h1>{user.username}</h1>
      <p>{user.email}</p>
    </div>
  );
}

Conditional Fetching

function UserProfile({ userId }: { userId: number | null }) {
  const { data: user } = UserService.useUser(
    "A", 
    userId!, // TypeScript non-null assertion
    { enabled: userId !== null } // Don't call if userId is null
  );
  
  if (!userId) return <div>Please select a user</div>;
  
  return <div>{user?.username}</div>;
}

Advanced Features

qs.stringify Usage

Uses qs library to serialize query parameters for GET requests.
import qs from "qs";

// Complex objects also converted to query string
const queryString = qs.stringify({ 
  subset: "A", 
  id: 123,
  filters: { status: "active" } 
});
// "subset=A&id=123&filters[status]=active"

// Used in Service
export async function getUser<T extends UserSubsetKey>(
  subset: T,
  id: number
): Promise<UserSubsetMapping[T]> {
  return fetch({
    method: "GET",
    url: `/api/user/findById?${qs.stringify({ subset, id })}`,
  });
}
Why use qs:
  • Nested object support (filters[status]=active)
  • Array serialization support (ids[]=1&ids[]=2)
  • Matches backend’s parsing method

Error Handling

Handle SonamuError in a type-safe manner.
import { UserService } from "@/services/services.generated";
import { isSonamuError } from "@/lib/sonamu.shared";

try {
  await UserService.updateProfile({
    username: "newname",
  });
} catch (error) {
  if (isSonamuError(error)) {
    // Sonamu error
    console.log("Status:", error.code);
    console.log("Message:", error.message);
    console.log("Validation Issues:", error.issues);
    
    // Handle Zod validation errors
    error.issues.forEach((issue) => {
      console.log(`${issue.path.join(".")}: ${issue.message}`);
    });
  } else {
    // General error
    console.error(error);
  }
}

Query Options Reuse

import { UserService } from "@/services/services.generated";
import { useQueryClient } from "@tanstack/react-query";

function SomeComponent() {
  const queryClient = useQueryClient();
  
  async function handleUpdate() {
    // Invalidate cache after update
    await UserService.updateProfile({ username: "newname" });
    
    // Invalidate specific query with Query Options
    queryClient.invalidateQueries(
      UserService.getUserQueryOptions("A", 123)
    );
  }
}

Prefetching

import { UserService } from "@/services/services.generated";
import { useQueryClient } from "@tanstack/react-query";

function UserList({ userIds }: { userIds: number[] }) {
  const queryClient = useQueryClient();
  
  // Preload on hover
  function handleMouseEnter(userId: number) {
    queryClient.prefetchQuery(
      UserService.getUserQueryOptions("A", userId)
    );
  }
  
  return (
    <ul>
      {userIds.map((id) => (
        <li
          key={id}
          onMouseEnter={() => handleMouseEnter(id)}
        >
          User {id}
        </li>
      ))}
    </ul>
  );
}

Cautions

Cautions when using Services:
  1. Never manually modify generated Service files (services.generated.ts)
  2. Subset parameter required: Must specify subset like getUser("A", id)
  3. It’s a Namespace so no new needed: Call UserService.getUser() directly
  4. TanStack Query Hooks should only be called inside components
  5. Use isSonamuError() type guard for error handling
  6. Use qs.stringify() for complex object serialization

Next Steps