Backend Frontend DevTools 15 min read

End-to-End Type Safety: Go → OpenAPI 3.0 → React Query Hooks + Zod Schemas

Hoang Dang Tan Phat (Kane)

Hoang Dang Tan Phat (Kane)

Apr 14, 2026

You have a Go REST API. You have a React frontend. You’re maintaining TypeScript interfaces by hand. Every time the backend changes a response field, the frontend silently breaks — or worse, doesn’t break until production.

This post walks through a pipeline that eliminates manual type maintenance entirely:

Go structs → OpenAPI 3.0 spec → TypeScript interfaces + React Query hooks + Zod schemas

One command regenerates everything. Types are always in sync. No annotation comments. No Swagger UI hacks.

The Problem

A typical Go + React project has types defined in two places:

// Go backend
type UserResponse struct {
    ID    string `json:"id"`
    Email string `json:"email"`
    Role  string `json:"role"`
}
// React frontend — manually written, can drift
export interface IUser {
    id: string;
    email: string;
    role: string;
}

When the backend adds status: string to the response, the frontend keeps compiling. No error. The field is just silently undefined at runtime.

The Solution: Three Tools

ToolRole
oaswrap/spec + fiberopenapiGenerate OpenAPI 3.0 from Go route registration (code-first, no annotations)
orvalGenerate TypeScript interfaces + React Query hooks + Zod schemas from OpenAPI spec
Go CLI commandExport the spec to a YAML file without running servers

Step 1: Generate OpenAPI from Go Routes

Why not swaggo/swag?

swaggo/swag works but has limitations:

  • Swagger 2.0 only (not OpenAPI 3.x)
  • Comment annotations — easy to forget, hard to validate
  • Separate from routing — the annotations and actual route registration can diverge

oaswrap/spec takes a different approach: you wrap your Fiber router and declare types inline with route registration using .With() chains.

Install

go get github.com/oaswrap/spec/adapter/fiberopenapi

Create a shared router factory

// cmd/shared/openapi.go
package shared

import (
    "github.com/gofiber/fiber/v2"
    "github.com/oaswrap/spec/adapter/fiberopenapi"
    "github.com/oaswrap/spec/option"
)

func NewOpenAPIRouter(app *fiber.App) fiberopenapi.Generator {
    return fiberopenapi.NewRouter(app,
        option.WithTitle("My API"),
        option.WithVersion("1.0"),
        option.WithDescription("My service"),
        option.WithSecurity("BearerAuth", option.SecurityHTTPBearer("Bearer")),
    )
}

Note: NewRouter returns Generator which embeds Router — it can both register routes and export the spec.

Register routes with OpenAPI metadata

// cmd/http/wallet/routes.go
func RegisterRoutes(r fiberopenapi.Router, walletSvc WalletService, tokenSvc TokenService) {
    w := r.Group("/api/v1/wallet", authMiddleware(tokenSvc)).With(
        option.GroupSecurity("BearerAuth"),
        option.GroupTags("wallet"),
    )

    w.Get("/", GetBalances(walletSvc)).With(
        option.Summary("Get all wallet balances"),
        option.Response(200, new(dto.WalletListResponse)),
    )
    w.Post("/deposit", Deposit(walletSvc)).With(
        option.Summary("Deposit funds"),
        option.Request(new(dto.DepositDTO)),
        option.Response(200, new(dto.WalletResponse)),
    )
}

Handlers stay unchanged — they’re still closure factories returning fiber.Handler. The .With() chain only adds metadata for OpenAPI generation.

Mark response fields as required

By default, oaswrap/spec marks all fields as optional in the spec. Add required:"true" struct tags so generated TypeScript interfaces have non-optional fields:

type WalletResponse struct {
    ID        string `json:"id"         required:"true"`
    UserID    string `json:"user_id"    required:"true"`
    AssetID   string `json:"asset_id"   required:"true"`
    Available string `json:"available"  required:"true"`
    Locked    string `json:"locked"     required:"true"`
}

Without this, orval generates id?: string instead of id: string — and every field access needs optional chaining.

Export spec via CLI command

Create a CLI command that registers all routes on a dummy Fiber app and writes the spec:

// cli/openapi_export.go
func RunOpenAPIExport(c *urfavecli.Context) error {
    output := c.String("output")
    if output == "" {
        output = "docs/openapi.yaml"
    }

    app := fiber.New(fiber.Config{DisableStartupMessage: true})
    r := shared.NewOpenAPIRouter(app)

    // Register all routes with nil deps (handlers never called)
    usersHTTP.RegisterRoutes(r, nil, nil, nil, nil, nil, nil, nil)
    walletHTTP.RegisterRoutes(r, nil, nil)
    orderHTTP.RegisterRoutes(r, nil, nil)
    marketHTTP.RegisterRoutes(app, r, nil, nil, nil, nil, nil)

    if err := r.WriteSchemaTo(output); err != nil {
        return fmt.Errorf("write spec: %w", err)
    }
    fmt.Printf("OpenAPI spec written to %s\n", output)
    return nil
}

Run it:

go run . openapi-export
# OpenAPI spec written to docs/openapi.yaml

No database, no Redis, no NATS needed — just route metadata.

Step 2: Generate Frontend Code with Orval

Install

cd web
pnpm add zod
pnpm add -D orval

Configure orval

// web/orval.config.ts
import { defineConfig } from "orval";

export default defineConfig({
    api: {
        input: { target: "../docs/openapi.yaml" },
        output: {
            mode: "tags-split",
            target: "src/core/api/generated",
            schemas: "src/core/api/generated/models",
            client: "react-query",
            httpClient: "axios",
            override: {
                mutator: {
                    path: "src/core/api/axios-instance.ts",
                    name: "axiosInstance",
                },
            },
        },
    },
    "api-zod": {
        input: { target: "../docs/openapi.yaml" },
        output: {
            mode: "tags-split",
            target: "src/core/api/generated",
            client: "zod",
            fileExtension: ".zod.ts",
        },
    },
});

Two outputs from the same spec:

  • api — React Query hooks + Axios functions + TypeScript interfaces
  • api-zod — Zod validation schemas

Custom Axios instance

Orval needs a custom Axios instance that handles your auth logic (token refresh, response unwrapping):

// web/src/core/api/axios-instance.ts
import Axios, { type AxiosRequestConfig } from "axios";

export const AXIOS_INSTANCE = Axios.create({
    baseURL: import.meta.env.VITE_API_BASE_URL || "",
    headers: { "Content-Type": "application/json" },
    withCredentials: true,
});

// Add auth interceptor, token refresh logic, etc.
// ...

// Orval mutator — unwraps { data, error, trace_id } response wrapper
export const axiosInstance = <T>(config: AxiosRequestConfig): Promise<T> => {
    return AXIOS_INSTANCE(config).then(({ data }) => {
        if (data && typeof data === "object" && "data" in data) {
            return (data as { data: T }).data;
        }
        return data as T;
    });
};

export default axiosInstance;

Generate

pnpm generate:api  # runs orval

What you get

src/core/api/generated/
├── models/                     # TypeScript interfaces
│   ├── dtoUserResponse.ts      # interface DtoUserResponse { id: string; email: string; ... }
│   ├── dtoWalletResponse.ts
│   ├── marketTickerResponse.ts
│   └── index.ts                # barrel export
├── auth/
│   ├── auth.ts                 # usePostApiV1AuthLogin(), useGetApiV1AuthMe(), etc.
│   └── auth.zod.ts             # PostApiV1AuthLoginBody, GetApiV1AuthMeResponse (Zod schemas)
├── wallet/
│   ├── wallet.ts               # useGetApiV1Wallet(), usePostApiV1WalletDeposit(), etc.
│   └── wallet.zod.ts
├── orders/
│   ├── orders.ts
│   └── orders.zod.ts
└── market/
    ├── market.ts
    └── market.zod.ts

Each tag gets its own file with:

  • Axios functionsgetApiV1Wallet(), postApiV1WalletDeposit()
  • React Query hooksuseGetApiV1Wallet(), usePostApiV1WalletDeposit()
  • Query key helpersgetGetApiV1WalletQueryKey()
  • Zod schemasGetApiV1WalletResponse, PostApiV1WalletDepositBody

Step 3: Wire It Up

Re-export types with your preferred aliases

// web/src/core/api/types.ts
export type { DtoUserResponse as IUser } from "./generated/models";
export type { DtoWalletResponse as IWallet } from "./generated/models";
export type { DtoOrderResponse as IOrder } from "./generated/models";
export type { MarketTickerResponse as ITicker } from "./generated/models";
// ...

Existing code keeps using IUser, IWallet, etc. — zero changes needed.

Use generated hooks directly

import { useGetApiV1MarketSummary } from "@/core/api/generated/market/market";

function MarketStats() {
    const { data, isLoading } = useGetApiV1MarketSummary();

    if (isLoading) return <Skeleton />;
    return <div>Active pairs: {data?.active_pairs}</div>;
}

Invalidate queries with generated key helpers

import { useQueryClient } from "@tanstack/react-query";
import { getGetApiV1WalletQueryKey } from "@/core/api/generated/wallet/wallet";

const queryClient = useQueryClient();
queryClient.invalidateQueries({ queryKey: getGetApiV1WalletQueryKey() });

Validate responses with Zod (optional)

import { GetApiV1AuthMeResponse } from "@/core/api/generated/auth/auth.zod";

const user = await getApiV1AuthMe();
const validated = GetApiV1AuthMeResponse.parse(user); // throws if shape is wrong

The Full Workflow

Adding a new endpoint takes 3 steps:

# 1. Add Go handler + register route with .With() metadata
# 2. Export spec
go run . openapi-export

# 3. Generate frontend
cd web && pnpm generate:api

That’s it. You get:

  • TypeScript interface with correct field types
  • React Query hook (useQuery for GET, useMutation for POST/PUT/DELETE)
  • Query key helper for cache invalidation
  • Zod schema for runtime validation

No manual type writing. No copy-paste. No drift.

Tools Evaluated (and Why Not)

ToolVerdict
swaggo/swagSwagger 2.0 only, annotation-heavy, spec can diverge from routes
go-apispecStatic analysis — failed on closure factory pattern + urfave/cli
openapi-zod-clientGenerates Zod v3 only, produces Partial types, includes Zodios boilerplate
oaswrap/spec + orvalCode-first OpenAPI 3.0, generates RQ hooks + Zod v4 schemas

Gotchas

1. Add required:"true" tags to all response struct fields. Without them, every TypeScript field is optional — orval follows the spec literally.

2. The Axios mutator must unwrap your response wrapper. If your API returns { data: T, error, trace_id }, the mutator should extract data.data, not data.

3. orval’s mode: "tags-split" organizes by OpenAPI tags. Make sure your routes have .With(option.Tags("wallet")) — otherwise everything lands in a single file.

4. WebSocket endpoints can’t be documented in OpenAPI. Keep WS-specific types manual or in a separate type file.

5. Run go run . openapi-export in CI to catch spec drift — if the generated spec changes, the PR diff shows it.

Repo Structure

├── cmd/http/{module}/
│   ├── routes.go          ← register routes with .With() OpenAPI metadata
│   ├── get_balances.go    ← handler (unchanged)
│   └── params.go          ← request param structs for path/query docs
├── cmd/shared/openapi.go  ← shared router factory
├── cli/openapi_export.go  ← CLI command to export spec
├── docs/openapi.yaml      ← generated spec (committed)
└── web/
    ├── orval.config.ts    ← orval configuration
    ├── src/core/api/
    │   ├── axios-instance.ts      ← shared Axios with auth interceptors
    │   ├── types.ts               ← re-exports with I-prefix aliases
    │   └── generated/             ← auto-generated (committed)
    │       ├── models/*.ts        ← TypeScript interfaces
    │       ├── {tag}/{tag}.ts     ← React Query hooks
    │       └── {tag}/{tag}.zod.ts ← Zod schemas
    └── package.json       ← "generate:api": "orval"

The docs/openapi.yaml and web/src/core/api/generated/ are committed to git. This makes PRs reviewable — you can see exactly what changed in the API contract and generated code.

go openapi react-query zod orval typescript code-generation fiber
Hoang Dang Tan Phat (Kane)

Hoang Dang Tan Phat (Kane)

Full-stack developer with 8+ years experience. Building scalable systems with Go, TypeScript, and React.