【フロントエンド】Orvalでzodは生成しないほうが良いかもしれない話
この記事の目次
本記事は【Advent Calendar 2025】の12日目の記事です。
結論
フロントエンドでは、OrvalでzodではなくFetch Clientを生成することを検討してみてはいかがでしょうか?
Orvalとは
Orvalとは、OpenAPIからTypeScriptのコードを生成できるツールです。
例えば、以下のようなコードが生成できます。
- Fetch Client
- React Query
- Zod
- Hono
このように、Orvalは様々な種類のコードを生成できるとても便利なツールです。
しかし、使い方によっては、逆に保守性の低下を引き起こす可能性があります。
特に、「フロントエンドでzodを生成させている」ことが課題となるケースがあります。
そこで、本記事ではフロントエンドでOrvalにzodを生成させることについて、いくつかの観点から考察していきます。
Fetch Clientという選択肢
フロントエンドにおいて、なぜOrvalを使用するのでしょうか?
それは、APIのレスポンスに型が欲しいからです。
zodを生成させることで型を得ることができますが、Fetch Clientを生成させることでも同様に実現でき、より簡潔にできる可能性があります。
zodとFetch Clientの比較
では、zodを生成させた場合とFetch Clientを生成させた場合のコードを比較してみましょう。
OpenAPI
openapi: 3.0.0
servers:
- url: 'http://petstore.swagger.io/v2'
info:
description: >-
This is a sample server Petstore server. For this sample, you can use the api key
`special-key` to test the authorization filters.
version: 1.0.0
title: OpenAPI Petstore
license:
name: Apache-2.0
url: 'https://www.apache.org/licenses/LICENSE-2.0.html'
tags:
- name: pet
description: Everything about your Pets
- name: store
description: Access to Petstore orders
- name: user
description: Operations about user
paths:
/pet:
post:
tags:
- pet
summary: Add a new pet to the store
description: ''
operationId: addPet
responses:
'200':
description: successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Pet'
application/json:
schema:
$ref: '#/components/schemas/Pet'
'405':
description: Invalid input
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
requestBody:
$ref: '#/components/requestBodies/Pet'
put:
tags:
- pet
summary: Update an existing pet
description: ''
operationId: updatePet
externalDocs:
url: "http://petstore.swagger.io/v2/doc/updatePet"
description: "API documentation for the updatePet operation"
responses:
'200':
description: successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Pet'
application/json:
schema:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid ID supplied
'404':
description: Pet not found
'405':
description: Validation exception
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
requestBody:
$ref: '#/components/requestBodies/Pet'
/pet/findByStatus:
get:
tags:
- pet
summary: Finds Pets by status
description: Multiple status values can be provided with comma separated strings
operationId: findPetsByStatus
parameters:
- name: status
in: query
description: Status values that need to be considered for filter
required: true
style: form
explode: false
deprecated: true
schema:
type: array
items:
type: string
enum:
- available
- pending
- sold
default: available
responses:
'200':
description: successful operation
content:
application/xml:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid status value
security:
- petstore_auth:
- 'read:pets'
/pet/findByTags:
get:
tags:
- pet
summary: Finds Pets by tags
description: >-
Multiple tags can be provided with comma separated strings. Use tag1,
tag2, tag3 for testing.
operationId: findPetsByTags
parameters:
- name: tags
in: query
description: Tags to filter by
required: true
style: form
explode: false
schema:
type: array
items:
type: string
responses:
'200':
description: successful operation
content:
application/xml:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid tag value
security:
- petstore_auth:
- 'read:pets'
deprecated: true
'/pet/{petId}':
get:
tags:
- pet
summary: Find pet by ID
description: Returns a single pet
operationId: getPetById
parameters:
- name: petId
in: path
description: ID of pet to return
required: true
schema:
type: integer
format: int64
responses:
'200':
description: successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Pet'
application/json:
schema:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid ID supplied
'404':
description: Pet not found
security:
- api_key: []
post:
tags:
- pet
summary: Updates a pet in the store with form data
description: ''
operationId: updatePetWithForm
parameters:
- name: petId
in: path
description: ID of pet that needs to be updated
required: true
schema:
type: integer
format: int64
responses:
'405':
description: Invalid input
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
requestBody:
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
name:
description: Updated name of the pet
type: string
status:
description: Updated status of the pet
type: string
delete:
tags:
- pet
summary: Deletes a pet
description: ''
operationId: deletePet
parameters:
- name: api_key
in: header
required: false
schema:
type: string
- name: petId
in: path
description: Pet id to delete
required: true
schema:
type: integer
format: int64
responses:
'400':
description: Invalid pet value
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
'/pet/{petId}/uploadImage':
post:
tags:
- pet
summary: uploads an image
description: ''
operationId: uploadFile
parameters:
- name: petId
in: path
description: ID of pet to update
required: true
schema:
type: integer
format: int64
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
additionalMetadata:
description: Additional data to pass to server
type: string
file:
description: file to upload
type: string
format: binary
/store/inventory:
get:
tags:
- store
summary: Returns pet inventories by status
description: Returns a map of status codes to quantities
operationId: getInventory
responses:
'200':
description: successful operation
content:
application/json:
schema:
type: object
additionalProperties:
type: integer
format: int32
security:
- api_key: []
/store/order:
post:
tags:
- store
summary: Place an order for a pet
description: ''
operationId: placeOrder
responses:
'200':
description: successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Order'
application/json:
schema:
$ref: '#/components/schemas/Order'
'400':
description: Invalid Order
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
description: order placed for purchasing the pet
required: true
'/store/order/{orderId}':
get:
tags:
- store
summary: Find purchase order by ID
description: >-
For valid response try integer IDs with value <= 5 or > 10. Other values
will generate exceptions
operationId: getOrderById
parameters:
- name: orderId
in: path
description: ID of pet that needs to be fetched
required: true
schema:
type: integer
format: int64
minimum: 1
maximum: 5
responses:
'200':
description: successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Order'
application/json:
schema:
$ref: '#/components/schemas/Order'
'400':
description: Invalid ID supplied
'404':
description: Order not found
delete:
tags:
- store
summary: Delete purchase order by ID
description: >-
For valid response try integer IDs with value < 1000. Anything above
1000 or nonintegers will generate API errors
operationId: deleteOrder
parameters:
- name: orderId
in: path
description: ID of the order that needs to be deleted
required: true
schema:
type: string
responses:
'400':
description: Invalid ID supplied
'404':
description: Order not found
/user:
post:
tags:
- user
summary: Create user
description: This can only be done by the logged in user.
operationId: createUser
responses:
default:
description: successful operation
security:
- api_key: []
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/User'
description: Created user object
required: true
/user/createWithArray:
post:
tags:
- user
summary: Creates list of users with given input array
description: ''
operationId: createUsersWithArrayInput
responses:
default:
description: successful operation
security:
- api_key: []
requestBody:
$ref: '#/components/requestBodies/UserArray'
/user/createWithList:
post:
tags:
- user
summary: Creates list of users with given input array
description: ''
operationId: createUsersWithListInput
responses:
default:
description: successful operation
security:
- api_key: []
requestBody:
$ref: '#/components/requestBodies/UserArray'
/user/login:
get:
tags:
- user
summary: Logs user into the system
description: ''
operationId: loginUser
parameters:
- name: username
in: query
description: The user name for login
required: true
schema:
type: string
pattern: '^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$'
- name: password
in: query
description: The password for login in clear text
required: true
schema:
type: string
responses:
'200':
description: successful operation
headers:
Set-Cookie:
description: >-
Cookie authentication key for use with the `api_key`
apiKey authentication.
schema:
type: string
example: AUTH_KEY=abcde12345; Path=/; HttpOnly
X-Rate-Limit:
description: calls per hour allowed by the user
schema:
type: integer
format: int32
X-Expires-After:
description: date in UTC when token expires
schema:
type: string
format: date-time
content:
application/xml:
schema:
type: string
application/json:
schema:
type: string
'400':
description: Invalid username/password supplied
/user/logout:
get:
tags:
- user
summary: Logs out current logged in user session
description: ''
operationId: logoutUser
responses:
default:
description: successful operation
security:
- api_key: []
'/user/{username}':
get:
tags:
- user
summary: Get user by user name
description: ''
operationId: getUserByName
parameters:
- name: username
in: path
description: The name that needs to be fetched. Use user1 for testing.
required: true
schema:
type: string
responses:
'200':
description: successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/User'
application/json:
schema:
$ref: '#/components/schemas/User'
'400':
description: Invalid username supplied
'404':
description: User not found
put:
tags:
- user
summary: Updated user
description: This can only be done by the logged in user.
operationId: updateUser
parameters:
- name: username
in: path
description: name that need to be deleted
required: true
schema:
type: string
responses:
'400':
description: Invalid user supplied
'404':
description: User not found
security:
- api_key: []
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/User'
description: Updated user object
required: true
delete:
tags:
- user
summary: Delete user
description: This can only be done by the logged in user.
operationId: deleteUser
parameters:
- name: username
in: path
description: The name that needs to be deleted
required: true
schema:
type: string
responses:
'400':
description: Invalid username supplied
'404':
description: User not found
security:
- api_key: []
externalDocs:
description: Find out more about Swagger
url: 'http://swagger.io'
components:
requestBodies:
UserArray:
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
description: List of user object
required: true
Pet:
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
$ref: '#/components/schemas/Pet'
description: Pet object that needs to be added to the store
required: true
securitySchemes:
petstore_auth:
type: oauth2
flows:
implicit:
authorizationUrl: 'http://petstore.swagger.io/api/oauth/dialog'
scopes:
'write:pets': modify pets in your account
'read:pets': read your pets
api_key:
type: apiKey
name: api_key
in: header
schemas:
Order:
title: Pet Order
description: An order for a pets from the pet store
type: object
properties:
id:
type: integer
format: int64
petId:
type: integer
format: int64
quantity:
type: integer
format: int32
shipDate:
type: string
format: date-time
status:
type: string
description: Order Status
enum:
- placed
- approved
- delivered
complete:
type: boolean
default: false
xml:
name: Order
Category:
title: Pet category
description: A category for a pet
type: object
properties:
id:
type: integer
format: int64
name:
type: string
pattern: '^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$'
xml:
name: Category
User:
title: a User
description: A User who is purchasing from the pet store
type: object
properties:
id:
type: integer
format: int64
username:
type: string
firstName:
type: string
lastName:
type: string
email:
type: string
password:
type: string
phone:
type: string
userStatus:
type: integer
format: int32
description: User Status
xml:
name: User
Tag:
title: Pet Tag
description: A tag for a pet
type: object
properties:
id:
type: integer
format: int64
name:
type: string
xml:
name: Tag
Pet:
title: a Pet
description: A pet for sale in the pet store
type: object
required:
- name
- photoUrls
properties:
id:
type: integer
format: int64
category:
$ref: '#/components/schemas/Category'
name:
type: string
example: doggie
photoUrls:
type: array
xml:
name: photoUrl
wrapped: true
items:
type: string
tags:
type: array
xml:
name: tag
wrapped: true
items:
$ref: '#/components/schemas/Tag'
status:
type: string
description: pet status in the store
deprecated: true
enum:
- available
- pending
- sold
xml:
name: Pet
ApiResponse:
title: An uploaded response
description: Describes the result of uploading an image resource
type: object
properties:
code:
type: integer
format: int32
type:
type: string
message:
type: stringorval.config.ts
/**
* @summary Add a new pet to the store
*/
export const addPetBodyCategoryNameRegExp = new RegExp(
'^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$',
);
export const addPetBody = zod
.object({
id: zod.number().optional(),
category: zod
.object({
id: zod.number().optional(),
name: zod.string().regex(addPetBodyCategoryNameRegExp).optional(),
})
.optional()
.describe('A category for a pet'),
name: zod.string(),
photoUrls: zod.array(zod.string()),
tags: zod
.array(
zod
.object({
id: zod.number().optional(),
name: zod.string().optional(),
})
.describe('A tag for a pet'),
)
.optional(),
status: zod
.enum(['available', 'pending', 'sold'])
.optional()
.describe('pet status in the store'),
})
.describe('A pet for sale in the pet store');
export const addPetResponseCategoryNameRegExp = new RegExp(
'^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$',
);
export const addPetResponse = zod
.object({
id: zod.number().optional(),
category: zod
.object({
id: zod.number().optional(),
name: zod.string().regex(addPetResponseCategoryNameRegExp).optional(),
})
.optional()
.describe('A category for a pet'),
name: zod.string(),
photoUrls: zod.array(zod.string()),
tags: zod
.array(
zod
.object({
id: zod.number().optional(),
name: zod.string().optional(),
})
.describe('A tag for a pet'),
)
.optional(),
status: zod
.enum(['available', 'pending', 'sold'])
.optional()
.describe('pet status in the store'),
})
.describe('A pet for sale in the pet store');zodの場合:
/**
* @summary Add a new pet to the store
*/
export const addPetBodyCategoryNameRegExp = new RegExp(
'^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$',
);
export const addPetBody = zod
.object({
id: zod.number().optional(),
category: zod
.object({
id: zod.number().optional(),
name: zod.string().regex(addPetBodyCategoryNameRegExp).optional(),
})
.optional()
.describe('A category for a pet'),
name: zod.string(),
photoUrls: zod.array(zod.string()),
tags: zod
.array(
zod
.object({
id: zod.number().optional(),
name: zod.string().optional(),
})
.describe('A tag for a pet'),
)
.optional(),
status: zod
.enum(['available', 'pending', 'sold'])
.optional()
.describe('pet status in the store'),
})
.describe('A pet for sale in the pet store');
export const addPetResponseCategoryNameRegExp = new RegExp(
'^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$',
);
export const addPetResponse = zod
.object({
id: zod.number().optional(),
category: zod
.object({
id: zod.number().optional(),
name: zod.string().regex(addPetResponseCategoryNameRegExp).optional(),
})
.optional()
.describe('A category for a pet'),
name: zod.string(),
photoUrls: zod.array(zod.string()),
tags: zod
.array(
zod
.object({
id: zod.number().optional(),
name: zod.string().optional(),
})
.describe('A tag for a pet'),
)
.optional(),
status: zod
.enum(['available', 'pending', 'sold'])
.optional()
.describe('pet status in the store'),
})
.describe('A pet for sale in the pet store');
Fetch Clientの場合:
/**
* @summary Add a new pet to the store
*/
export type addPetResponse200 = {
data: Pet;
status: 200;
};
export type addPetResponse405 = {
data: null;
status: 405;
};
export type addPetResponseComposite = addPetResponse200 | addPetResponse405;
export type addPetResponse = addPetResponseComposite & {
headers: Headers;
};
export const getAddPetUrl = () => {
return `/pet`;
};
export const addPet = async (
petBody: PetBody,
options?: RequestInit,
): Promise<addPetResponse> => {
const res = await fetch(getAddPetUrl(), {
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(petBody),
});
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
const data: addPetResponse['data'] = body ? JSON.parse(body) : {};
return { data, status: res.status, headers: res.headers } as addPetResponse;
};
zodを生成させた場合、生成されるのはあくまでもzodのスキーマなので、実際にfetchする処理は自分で書く必要があります。
それに対して、Fetch Clientを生成させた場合はfetchする処理まで生成してくれるので、ボイラープレートを減らすことができます。
型安全性についての考察
Fetch Clientで生成させたコードを見てもらうとわかりますが、asを使用して型のアサーションを行っています。
そうです。Fetch Clientで生成したコードは厳密には型安全ではありません。
しかし、ここで考えてみたいことがあります。
型安全ではないことによって、問題が発生するのはどのようなケースでしょうか?
それは、OpenAPIのスキーマと実際にバックエンドから返ってくるスキーマが異なるケースです。
そして、それは果たしてフロントエンドの、しかもランタイム上で検知すべきことなのでしょうか?
それを踏まえると、OpenAPIとバックエンドの齟齬がフロントエンドのランタイム上で判明するのは理想的なタイミングとは言えないかもしれません。
したがって、この問題はバックエンドの責務と考え、バックエンド側のテストで対処する方が適切だと考えられます。
バックエンドはフロントエンドを信頼してはいけませんが、フロントエンドはバックエンドを信頼するという考え方もできます。
ランタイム検証についての考え方
実際問題、ランタイムエラーが発生したらどうするのか? という懸念もあるかと思います。
その場合、素直にエラーをthrowするという選択肢があります。
OpenAPIとバックエンドに齟齬があるという致命的な問題が発生している場合、フロントエンド側でできることは限られています。
また、Next.jsならerror.tsxを配置しておくことで、エラー画面を表示することができます。
catchした後どうするのか?
を念頭においてエラーハンドリングを設計しましょう。
クエリパラメータやリクエストボディのバリデーションについて
フロントエンドからバックエンドのAPIにリクエストを送る前に、クエリパラメータやリクエストボディのバリデーションを行いたいというユースケースがあると思います。
ここでは、一つの考え方として、その段階でのバリデーションの必要性について検討してみます。
実際は、その直後にバックエンドがバリデーションを行います。
したがって、フロントエンド側での重複したバリデーションは省略できる場合が多いです。
また、フロントエンドで行うバリデーションはUXのためであるという視点を持つことが重要です。
つまり、セキュリティや不正な値を防ぐためのバリデーションはバックエンドで行い、フロントエンドとバックエンドで二度同じバリデーションを行う必要性は低いと考えることができます。
フォームのバリデーションについて
UXのためのフォームのバリデーションで、zodのスキーマが欲しくなるケースがあるかもしれません。
その場合は、フォームのスキーマをAPIのスキーマとは別に定義することをおすすめします。
なぜなら、フォームのスキーマはAPIのスキーマと必ず対応しているとは限らないからです。
例えば、郵便番号を入力するとき、API側では半角数字の文字列を期待しますが、フォーム側ではUXのために全角数字の文字列も受け取れるようにしたい場合があります。
また、数値入力でも、API側では数値型を期待しますが、フォーム側では一時的に文字列として扱い、カンマ区切りの表示に対応したい場合があります。
それ以外でも、API側は完成形のオブジェクトを期待しますが、フォームでは段階的に異なる形状のデータを扱うような場合があります。
このような場合、API側のスキーマは流用できません。フロントエンド側で独自に定義する必要があります。
こうなると、APIのスキーマを流用するものと、しないものが混在することになります。
そして、現状流用できているスキーマも、後から変更される可能性があります。
つまり、APIのスキーマとフォームのスキーマは本質的に異なるものであると考えることができます。
したがって、フォームのスキーマは仮にAPIのスキーマと一致していても、別で定義することを検討してみてはいかがでしょうか。
zodを生成した方が適しているケース
ここまで、zodを生成しない選択肢について考察してきましたが、zodを生成した方が適しているケースもあります。
外部サービスのAPIを使用する場合
自分たちが管理していない外部APIを使用する場合は、zodによるランタイム検証が有効な選択肢となります。
なぜなら、OpenAPIスキーマとバックエンドの実装に齟齬があっても、バックエンド側で修正することができないためです。
また、外部APIは予告なく仕様が変更されることもあります。
このような場合、フロントエンド側で防御的にランタイム検証し、不正なデータを早期に検出することで、予期しないエラーを防ぐことができます。
バックエンドでTypeScriptを使用している場合
バックエンドでTypeScriptを使用している場合、Orvalによるzod生成を活用することで、バックエンド側でバリデーションを行うことができます。
ただし、これはフロントエンドの話ではなく、バックエンドの話です。
(ここまで「フロントエンドにおける」と強調してきたのはこのためです)
バックエンドでは、フロントエンドから送られてくるリクエストボディやクエリパラメータを信頼すべきではありません。
したがって、バックエンド側でランタイムバリデーションを行う必要があります。
このとき、OpenAPIからzodスキーマを生成することで、バリデーションのコードを自動生成でき、保守性を向上させることができます。
補足: APIの型の使い方について
zodの話とは少し逸れますが、Orvalを使う際に注意したい点として、APIの型をコンポーネントからAPIクライアントまで使い回すというアンチパターンがあります。
前提として、APIのレスポンスはJSONであり、それはシリアライズされたDTOに過ぎません。
JSONはドメイン知識を持たず、ドメインモデルとして機能しないため、Orvalが生成した型をフロントエンド側であたかもドメインモデルであるかのように直接依存すると、様々な箇所で不整合が発生する可能性があります。
そのため、フロントエンドではフロントエンド用のドメインモデルを定義することをおすすめします。
そして、DTOをドメインモデルに変換するMapperを実装することも、一つの有効なアプローチです。
この辺りの話は以下の記事が参考になるため、よろしければ読んでみてください。
まとめ
本記事では、フロントエンドでOrvalを使用する際に、zodではなくFetch Clientを生成するという選択肢について考察しました。
重要なポイントは以下の4つです。
- Fetch Clientを生成することで、zodよりも簡潔に型がついたAPI通信の処理を書ける
- ランタイム上でのOpenAPIとバックエンドの齟齬検出は、バックエンドのテストで対処する考え方もある
- フォームのスキーマとAPIのスキーマは本質的に異なる目的を持つ
- APIのレスポンスはシリアライズされたDTOであり、ドメインモデルとは区別して扱うことが望ましい
これらは一つの考え方であり、プロジェクトの状況やチームの方針によって最適な選択は異なります。
それぞれのアプローチのトレードオフを理解した上で、プロジェクトに適した方法を選択することをおすすめします。
※本記事は2025年12月時点の情報です。