This article is the Day 2 entry of the KINTO Technologies Advent Calendar 2025 . Introduction Hello! I'm high-g ( @high_g_engineer ) from the Master Maintenance Tool Development Team in the KINTO Backend Development Group, KINTO Development Division at Osaka Tech Lab. In modern frontend development with heavy API integration, have you ever experienced challenges like these? Manually writing API type definitions often leads to missed updates when the spec changes Auto-generated files scattered everywhere often make it unclear where to import from Team members interpreting directory structures differently often lead to debates during code reviews The keywords to solve these challenges are type safety , schema-driven , auto-generation , and directory design . This article introduces an approach where OpenAPI serves as the single source of truth for auto-generating type-safe code, managed according to Feature-Sliced Design rules. Specifically, we'll walk through what code Orval generates and explain effective design patterns aligned with Feature-Sliced Design's directory structure. What This Article Covers The flow of outputting types and custom hooks from OpenAPI using Orval Detailed examples of the code Orval generates Feature-Sliced Design's layer structure and import rules Design patterns for managing Orval-generated code within Feature-Sliced Design's directory structure Target Audience Frontend developers tired of manually managing REST APIs and type definitions Developers using TypeScript + React Those interested in designs resilient to API changes Those interested in establishing directory structure rules Foundational Knowledge OpenAPI OpenAPI is a standard for defining HTTP APIs in a machine-readable format. By describing API specifications in YAML or JSON, you gain benefits like: Clearly defined API inputs and outputs Automated documentation generation Prevention of discrepancies between client and server Example: Partial OpenAPI Definition (Simplified) openapi: 3.1.0 paths: /posts: get: summary: Get list of posts parameters: - name: page in: query schema: type: integer responses: "200": description: Success content: application/json: schema: $ref: "#/components/schemas/GetPostsResponse" post: summary: Create a post requestBody: content: application/json: schema: $ref: "#/components/schemas/CreatePostRequest" responses: "201": description: Created content: application/json: schema: $ref: "#/components/schemas/CreatePostResponse" /posts/{postId}: put: summary: Update a post parameters: - name: postId in: path required: true schema: type: string requestBody: content: application/json: schema: $ref: "#/components/schemas/UpdatePostRequest" responses: "200": description: Success content: application/json: schema: $ref: "#/components/schemas/Post" delete: summary: Delete a post parameters: - name: postId in: path required: true schema: type: string responses: "204": description: No Content components: schemas: Post: type: object required: [id, title, createdAt, updatedAt, status] properties: id: type: string title: type: string body: type: string createdAt: type: string format: date-time updatedAt: type: string format: date-time status: type: string enum: [draft, published, archived] GetPostsResponse: type: object properties: items: type: array items: $ref: "#/components/schemas/Post" total: type: integer page: type: integer CreatePostRequest: type: object required: [title] properties: title: type: string body: type: string CreatePostResponse: allOf: - $ref: "#/components/schemas/Post" - type: object properties: createdBy: type: string UpdatePostRequest: type: object properties: title: type: string body: type: string status: type: string enum: [draft, published, archived] This YAML defines the following: /posts endpoint: list retrieval (GET) and creation (POST) /posts/{postId} endpoint: update (PUT) and deletion (DELETE) About Schema-Driven Development Problems with Traditional Manual Management Previously, frontend developers performed tasks like these manually: // Manually writing type definitions type Post = { id: string; title: string; body?: string; createdAt: string; updatedAt: string; status: "draft" | "published" | "archived"; }; // Manually writing API calls const getPost = async (id: string): Promise<Post> => { const response = await fetch(`/api/posts/${id}`); return response.json(); }; This approach has the following problems: Cost of manual updates Check OpenAPI → manually update type definitions → update all usage sites Risk of missed updates Type definitions and actual API specs get out of sync Easy to miss updates when the same type is used in multiple places Documentation and code desynchronization OpenAPI ≠ implementation code can happen The Schema-Driven Development Approach To address these manual management problems, a development methodology emerged: define the schema (API specification) first, then proceed with implementation. Traditional: Implementation → Documentation (afterthought) → Discrepancies with spec Schema-driven: Schema definition → Auto-generation → Implementation → Done (Implementation = Documentation, always in sync) Characteristics of Schema-Driven Development Implementation = Documentation : API specs and code are always synchronized Type safety : API inconsistencies detected at compile time Development efficiency : No manual type definition work Team collaboration : Both frontend and backend reference the same OpenAPI Orval Orval is a tool that auto-generates TypeScript type definitions and custom hooks with a single command from OpenAPI specifications. Main Features of Orval Feature Description Auto-generated type definitions Automatically creates types for API requests and responses Auto-generated custom hooks Also auto-generates hooks for TanStack Query and others Multiple library support Supports not just TanStack Query but also Axios and other libraries Mock generation Can also generate mock data for testing Benefits of Using Orval Time savings : Zero time spent hand-writing type definitions or API call code Error prevention : Eliminates typos and spec misreadings from manual writing Always current : Just regenerate when OpenAPI is updated to stay current Orval's Role in Schema-Driven Development Summarizing the content so far, Orval's role in schema-driven development is as follows: OpenAPI (single source of truth) ↓ Auto-generation by Orval keeps type definitions + TanStack Query hooks always in sync ↓ Low-cost, type-safe development is possible Feature-Sliced Design As mentioned at the beginning, the ongoing project adopts Feature-Sliced Design as an architectural pattern for frontend code organization. Feature-Sliced Design is an architecture that organizes the codebase using three concepts: Layers , Slices , and Segments . Concept Description Examples Layer Division by application responsibility. From top: app → pages → features → entities → shared (5 layers). app handles routing and layouts for the entire app, pages handles screens corresponding to URLs app/ , pages/ , features/ Slice Division unit by business domain or feature within each layer features/auth/ , entities/user/ Segment Division by technical role within a slice ui/ , model/ , api/ src/ ├── features/ ← Layer │ ├── auth/ ← Slice │ │ ├── ui/ ← Segment │ │ ├── model/ ← Segment │ │ └── index.ts This structure clarifies where to put what, enabling the team to unify code placement rules. Feature-Sliced Design Directory Structure Our team operates with the following directory structure. The segment divisions ( api/ , model/ , ui/ , etc.) are customized to fit the project. workspaces/typescript/src/ ├── app/ ← ① Application layer: routing, global settings │ ├── layouts/ Layouts used across all pages │ ├── routes/ Routing definitions │ └── App.tsx Root tsx file │ ├── pages/ ← ② Pages layer: each page component (corresponds to URL) │ ├── users/ │ └── login/ │ ├── features/ ← ③ Features layer: reusable business logic │ ├── {slice}/ Divided by domain into units called slices (e.g., user, auth) │ │ ├── {component}/ Components belonging to the domain │ │ │ ├── model/ Logic portion │ │ │ ├── ui/ UI portion │ │ │ └── index.ts Public API (barrel file) │ │ ... │ ... │ ├── entities/ ← ④ Entities layer: business domain definitions │ ├── user/ Various domains │ │ ├── @x Cross-import notation *described later │ │ ├── api/ Imports and uses auto-generated files from shared/ (facade) │ │ │ ├── hooks.ts API hooks │ │ │ └── index.ts Public API (barrel file) │ │ ├── model/ Domain logic │ │ ├── ui/ Minimal UI staying within the domain │ │ └── index.ts Public API (barrel file) │ ... │ └── shared/ ← ⑤ Shared layer: project-independent utilities ├── api/ │ └── generated/ Auto-generated files by Orval (modification prohibited) │ ├── types.ts │ ├── hooks.ts │ └── client.ts ├── config/ Configuration constants ├── errors/ Commonly used error functions ├── lib/ Utility functions └── ui/ Generic UI components Feature-Sliced Design Layer Import Restriction Rules The most important rule of Feature-Sliced Design: A layer can only import from layers below itself. Additionally, mutual imports between the same layer are also prohibited in principle (exception described later with @x notation). app ← Top level (highest abstraction) ↓ import allowed pages ↓ features ↓ entities * shared can be imported from any layer This means the following rules are established within the project: ✅ pages/ can import from features/, entities/, shared/ ✅ features/ can import from entities/, shared/ ✅ entities/ can import from shared/ ❌ entities/ must not import from features/ or pages/ ❌ shared/ must not import from any other layer Special Role of the Entities Layer: entities/@x (Cross-Import Notation) However, in the entities layer, business domains often relate to each other. For example, cases like "Post references User" occur. To solve this, a special import method allowed only within the entities layer is the @x notation. Directory Structure Example entities/ ├── user/ │ ├── @x/ │ │ └── post.ts # Types/functions exposed for external slices │ ├── model/ │ │ └── types.ts # Type definitions used internally │ ├── ui/ │ └── index.ts # Normal public API │ └── post/ ├── model/ │ └── usePost.ts # Wants to reference user's types from here └── index.ts Usage Example // When using entities/user from entities/post/model/usePost.ts // ❌ Normal import (Feature-Sliced Design violation: import between same layer) import type { User } from "@/entities/user"; // ✅ Cross-import using @x (allowed) import type { User } from "@/entities/user/@x/post"; // The @x directory represents "cross-import-specific API that this slice exposes externally." By using @x , it becomes explicit that something is intentionally exposed externally, making dependency tracking easier. Now that we've covered the foundational knowledge, let's get into the main topic. How to Use Orval and Output Code Setup The ongoing project uses pnpm as the package manager. Also, we'll proceed assuming OpenAPI is already defined. # Install Orval pnpm add -D orval Next, create the Orval configuration file. Note: hooks are defined to format auto-generated code with Biome. // orval.config.ts import { defineConfig } from "orval"; const API_DIR = "./src/shared/api"; const INPUT_DIR = "../../docs/api"; const GENERATED_DIR = `${API_DIR}/generated`; export default defineConfig({ postApi: { hooks: { afterAllFilesWrite: "pnpm format:write:generate", }, input: { target: `${INPUT_DIR}/openapi.yaml`, }, output: { clean: true, biome: true, client: "react-query", override: { mutator: { path: `${API_DIR}/customInstance.ts`, name: "useCustomInstance", }, query: { useSuspenseQuery: true, version: 5, }, }, schemas: `${GENERATED_DIR}/model`, target: `${GENERATED_DIR}/hooks/index.ts`, }, }, }); Next, create a custom instance that executes API requests. This is used as the mutator specified in the Orval configuration. // src/shared/api/customInstance.ts import { ApiHttpError, type ErrorDetail } from "../errors"; import { getAccessToken } from "../lib"; const BASE_URL = import.meta.env.VITE_API_BASE_URL || ""; // Type definition for request configuration export type RequestConfig = { url: string; method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; headers?: Record<string, string>; params?: Record<string, unknown>; data?: unknown; signal?: AbortSignal; }; // Request function using Fetch API const fetchApi = async <T>(config: RequestConfig): Promise<T> => { const { url, method, headers = {}, params, data, signal } = config; // Get authentication token const token = getAccessToken(); // Build query parameters const queryString = params ? `?${new URLSearchParams(params as Record<string, string>).toString()}` : ""; const fullUrl = `${BASE_URL}${url}${queryString}`; // Build headers const requestHeaders: Record<string, string> = { "Content-Type": "application/json", ...headers, }; if (token) { requestHeaders.Authorization = `Bearer ${token}`; } // Build request options const options: RequestInit = { method, headers: requestHeaders, signal, }; if (data && ["POST", "PUT", "PATCH"].includes(method)) { options.body = JSON.stringify(data); } const response = await fetch(fullUrl, options); // Error handling if (!response.ok) { let errorMessage = `API error: ${response.status}`; let errorDetails: ErrorDetail[] = []; try { const errorData = await response.json(); errorDetails = errorData?.errors?.details ?? []; if (typeof errorData.message === "string") { errorMessage = errorData.message; } } catch { // Use default message if JSON parsing fails } throw new ApiHttpError({ status: response.status, message: errorMessage, details: errorDetails, }); } // For 204 No Content if (response.status === 204) { return null as T; } return response.json(); }; // Custom instance function used by Orval export const useCustomInstance = <T>(config: RequestConfig): Promise<T> => { const controller = new AbortController(); const promise = fetchApi<T>({ ...config, signal: controller.signal, }); // For TanStack Query's cancel functionality // @ts-expect-error dynamically adding cancel property promise.cancel = () => controller.abort(); return promise; }; export default useCustomInstance; This useCustomInstance is used when executing HTTP requests within the hooks that Orval generates. You can centralize project-specific settings here, such as attaching authentication tokens and error handling. In actual projects, token refresh processing and retry logic are often added. For details, see the Orval Official Documentation - Custom Client . All that’s left is to run the code generation. # Run code generation pnpm orval In the ongoing project, we periodically run pnpm orval to batch-apply API spec changes. Actual Examples of Orval-Generated Code Now let's look at specific examples of what Orval actually generates. Generated Output 1: Type Definitions From OpenAPI's Post schema, TypeScript types like the following are auto-generated. // src/shared/api/generated/types.ts // ↓ Auto-generated from OpenAPI export type Post = { id: string; title: string; body?: string; createdAt: string; // ISO 8601 format updatedAt: string; status: "draft" | "published" | "archived"; }; export type GetPostsResponse = { items: Post[]; total: number; page: number; }; export type CreatePostRequest = { title: string; body?: string; }; export type CreatePostResponse = Post & { createdBy: string; }; export type UpdatePostRequest = { title?: string; body?: string; status?: "draft" | "published" | "archived"; }; Important Points OpenAPI schemas become types directly enum is converted to TypeScript Union Types Required/optional ( ? ) distinction is automatically determined Since it's a generated file, do not modify it (will be overwritten on next run) Generated Output 2: TanStack Query Custom Hooks Orval also auto-generates TanStack Query hooks. The following is a simplified example for easier understanding (actual generated code includes custom instances and detailed type definitions). // src/shared/api/generated/hooks.ts // ↓ Orval generates TanStack Query hooks (simplified example) import { useSuspenseQuery, useMutation } from "@tanstack/react-query"; import type { UseSuspenseQueryOptions, UseMutationOptions, } from "@tanstack/react-query"; import type { Post, GetPostsResponse, CreatePostRequest, CreatePostResponse, UpdatePostRequest, } from "./model"; import { useCustomInstance } from "../customInstance"; type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1]; // GET request → useSuspenseQuery hook export const useGetPosts = < TData = Awaited<ReturnType<ReturnType<typeof useCustomInstance<GetPostsResponse>>>>, TError = Error, >( options?: { query?: Partial<UseSuspenseQueryOptions<GetPostsResponse, TError, TData>>; request?: SecondParameter<ReturnType<typeof useCustomInstance>>; } ) => { const customInstance = useCustomInstance<GetPostsResponse>(); return useSuspenseQuery({ queryKey: ["posts"], queryFn: () => customInstance({ url: `/api/posts`, method: "GET" }), ...options?.query, }); }; // POST request → useMutation hook export const useCreatePost = <TError = Error, TContext = unknown>( options?: { mutation?: UseMutationOptions<CreatePostResponse, TError, CreatePostRequest, TContext>; request?: SecondParameter<ReturnType<typeof useCustomInstance>>; } ) => { const customInstance = useCustomInstance<CreatePostResponse>(); return useMutation({ mutationFn: (data: CreatePostRequest) => customInstance({ url: `/api/posts`, method: "POST", data, }), ...options?.mutation, }); }; // PUT request export const useUpdatePost = <TError = Error, TContext = unknown>( postId: string, options?: { mutation?: UseMutationOptions<Post, TError, UpdatePostRequest, TContext>; request?: SecondParameter<ReturnType<typeof useCustomInstance>>; } ) => { const customInstance = useCustomInstance<Post>(); return useMutation({ mutationFn: (data: UpdatePostRequest) => customInstance({ url: `/api/posts/${postId}`, method: "PUT", data, }), ...options?.mutation, }); }; // DELETE request export const useDeletePost = <TError = Error, TContext = unknown>( postId: string, options?: { mutation?: UseMutationOptions<void, TError, void, TContext>; request?: SecondParameter<ReturnType<typeof useCustomInstance>>; } ) => { const customInstance = useCustomInstance<void>(); return useMutation({ mutationFn: () => customInstance({ url: `/api/posts/${postId}`, method: "DELETE", }), ...options?.mutation, }); }; Convenience of These Hooks TypeScript type inference automatically infers data type as GetPostsResponse Error handling is also type-safe (Error type is determined) TanStack Query features like caching and refetching work as-is No manual API URL entry needed (prevents URL typos) Key Points for Using Orval-Generated Code Feature Benefit Automatic OpenAPI tracking API spec change → re-run → fully synchronized Types and hooks are linked Return type of useGetPosts is also auto-inferred Utilizes TypeScript generics Error handling is also type-safe Plugin extensible Can add custom generation logic Strong for API versioning Supports generation from older API spec versions Generated Code Must Not Be Modified Running pnpm orval overwrites type definitions and custom hooks, so files under src/shared/api/generated/ are modification prohibited . // ❌ Do not modify directly like this // src/shared/api/generated/hooks.ts export const useGetPosts = () => { // ↓ This code will be overwritten on Orval re-run return useSuspenseQuery({ // ... }); }; Customization Is Done in the Entities Layer When customization is needed, wrap in the entities layer to provide your own interface. This centralizes dependencies on generated code in one place. // src/entities/post/api/hooks.ts import { useGetPosts as useGetPostsGenerated } from "@/shared/api/generated"; /** * Provides a user-friendly interface * - Hides details of Orval-generated code * - Returns organized return values */ export const usePosts = () => { const { data, isLoading, error } = useGetPostsGenerated(); return { posts: data?.items ?? [], isLoading, hasError: !!error, }; }; Detailed implementation patterns are explained in the next chapter. Implementation Patterns and Structural Design From here, we'll introduce 3 design patterns for effectively using Orval-generated code. Pattern A: Simple Wrapping Scenario : API to get a list of posts Step 1: Check Orval-Generated Code The useGetPosts shown in "Generated Output 2: TanStack Query Custom Hooks" above is used as-is. Step 2: Wrap in Entities Layer // src/entities/post/api/hooks.ts import { useGetPosts as useGetPostsGenerated } from "@/shared/api/generated"; /** * Custom hook to get list of posts * Isolates dependency on shared/api/generated to entities layer */ export const usePosts = () => { const { data, isLoading, error } = useGetPostsGenerated(); return { posts: data?.items ?? [], isLoading, hasError: !!error, }; }; Step 3: Public API // src/entities/post/api/index.ts export { usePosts } from "./hooks"; Step 4: Use in Features Layer // src/features/PostManagement/ui/PostList.tsx import { usePosts } from "@/entities/post/api"; function PostList() { const { posts, isLoading } = usePosts(); if (isLoading) return <div>Loading...</div>; return ( <ul> {posts.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> ); } Benefits of This Pattern Orval-generated code changes are limited to entities/post/api features/PostManagement only needs to know the simple interface Testing also works with mocking just entities/post/api From a Feature-Sliced Design Perspective entities/post/api creates a boundary between Orval (external) and features (internal) Features don't know the details of generated code Modification scope can be limited to entities Pattern B: Combining Multiple APIs Scenario : When both "list of posts + post details" are needed Multiple API calls need to be combined. This is also handled in the entities layer. Note: The following example assumes a useGetPostDetails hook is separately generated by Orval. Step 1: Combine Multiple APIs in Entities Layer // src/entities/post/api/hooks.ts import { useGetPosts as useGetPostsGenerated, useGetPostDetails as useGetPostDetailsGenerated, } from "@/shared/api/generated"; /** * Combines multiple API calls * Callers don't need to be aware of this complexity */ export const usePostWithDetails = (postId: string) => { const { data: posts, isLoading: postsLoading } = useGetPostsGenerated(); const { data: details, isLoading: detailsLoading } = useGetPostDetailsGenerated(postId); return { posts: posts?.items ?? [], details: details ?? null, isLoading: postsLoading || detailsLoading, // Also provide convenient derived data hasDetails: !!details, }; }; Step 2: Use from Features Layer Callers don't need to know the complexity. // src/features/PostManagement/ui/PostDetail.tsx import { usePostWithDetails } from "@/entities/post/api"; function PostDetail({ postId }: Props) { const { posts, details, isLoading, hasDetails } = usePostWithDetails(postId); // Hide Complexity in entities layer! return <div>{hasDetails && <PostInfo details={details} />}</div>; } Pattern C: Unified Error Handling Scenario : When you want to handle errors in a common format Convert Orval-generated error types to custom error types. Step 1: Define and Convert Error Types in Entities Layer // src/entities/post/api/hooks.ts export type ApiError = { message: string; code: "NETWORK_ERROR" | "NOT_FOUND" | "UNAUTHORIZED" | "SERVER_ERROR"; details?: unknown; }; export type UsePostsResult = { posts: Post[]; isLoading: boolean; error: ApiError | null; retry: () => void; }; export const usePosts = (): UsePostsResult => { const { data, isLoading, error, refetch } = useGetPostsGenerated(); // Convert Orval-generated error type to custom error type const mappedError: ApiError | null = error ? { message: error.message || "An error occurred", code: mapErrorCode(error), details: error, } : null; return { posts: data?.items ?? [], isLoading, error: mappedError, retry: () => refetch(), }; }; // Helper function // TanStack Query's error is treated as Error type // Assumes custom instance throws Error with status code type ApiErrorWithStatus = Error & { status?: number }; function mapErrorCode(error: unknown): ApiError["code"] { if (!navigator.onLine) return "NETWORK_ERROR"; const apiError = error as ApiErrorWithStatus; if (apiError.status === 404) return "NOT_FOUND"; if (apiError.status === 401) return "UNAUTHORIZED"; return "SERVER_ERROR"; } Step 2: Unified Error Processing in Features Layer Error handling becomes unified on the caller side. // src/features/PostManagement/ui/PostList.tsx import { usePosts } from "@/entities/post/api"; function PostList() { const { posts, isLoading, error, retry } = usePosts(); if (error) { return ( <div> <p>Error: {error.message}</p> <button onClick={retry}>Retry</button> </div> ); } // ... normal processing below } Architecture Diagram: Orval + Feature-Sliced Design Here's a diagram summarizing the patterns so far. Since dependency directions are unified, the scope of change impact becomes clear. shared/api/generated/ ← Orval output (modification prohibited) ├─ useGetPosts ├─ useCreatePost ├─ useGetPostDetails └─ types.ts ↓ [Boundary] ↓ entities/post/api/ ← Layer wrapping Orval output (modifiable) ├─ usePosts (customized version) ├─ usePostWithDetails (multiple API combination) ├─ ApiError type └─ index.ts (public API) ↓ features/ ← Features layer ├─ PostManagement/ │ ├─ ui/PostList.tsx │ ├─ ui/PostDetail.tsx │ ├─ lib/... │ └─ index.ts ... ↓ pages/ ← Pages layer └─ PostPage/ ↓ app/ ← Application layer ├─ routes/ └─ ... Impressions After Adoption ✅ Benefits Dramatically improved type safety : Cannot go back to development with manually typed definitions. High resilience to API changes : Modifications complete in one place (entities layer). Documentation = Code : OpenAPI and code can always stay synchronized. Improved team-wide efficiency : Smooth flow from API design → implementation → testing. Fewer bugs : Bugs from type mismatches have nearly disappeared. ⚠️ Important Notes Learning cost for the entire team : Feature-Sliced Design is an architecture that takes time to master, requiring understanding from all team members. Wait time until OpenAPI is finalized : For UI implementation involving API spec changes, you need to wait for OpenAPI updates to complete. As a countermeasure, using mock APIs like MSW allows frontend development to proceed in parallel. Need for compatibility checks during Orval version upgrades : During Orval major version upgrades, generated code format may change, so checking release notes before upgrading is necessary. Summary Schema-driven development using Orval significantly improves resilience to API changes and type safety in frontend development. In the ongoing project, Orval was introduced from the start, reducing communication costs between backend and frontend engineers and nearly eliminating wasteful implementation costs. Additionally, while adopting Feature-Sliced Design took time for the entire team to understand and implement in code, the clear rules improved code readability and maintainability. If you're experiencing challenges like the following, please try the Orval × Feature-Sliced Design combination: Manually writing API types and custom hooks, incurring costs Schema-driven development is already adopted, but there are no directory structure rules Auto-generated files are imported from various places Thank you for reading to the end. References Orval Official Documentation OpenAPI Specification v3.1.0 TanStack Query Feature-Sliced Design Official Documentation