2025/12/16

テクノロジー

【フロントエンド】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: string
    orval.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月時点の情報です。

    著者:マイナビエンジニアブログ編集部