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
| Tool | Role |
|---|---|
oaswrap/spec + fiberopenapi | Generate OpenAPI 3.0 from Go route registration (code-first, no annotations) |
| orval | Generate TypeScript interfaces + React Query hooks + Zod schemas from OpenAPI spec |
| Go CLI command | Export 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 interfacesapi-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 functions —
getApiV1Wallet(),postApiV1WalletDeposit() - React Query hooks —
useGetApiV1Wallet(),usePostApiV1WalletDeposit() - Query key helpers —
getGetApiV1WalletQueryKey() - Zod schemas —
GetApiV1WalletResponse,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)
| Tool | Verdict |
|---|---|
| swaggo/swag | Swagger 2.0 only, annotation-heavy, spec can diverge from routes |
| go-apispec | Static analysis — failed on closure factory pattern + urfave/cli |
| openapi-zod-client | Generates Zod v3 only, produces Partial types, includes Zodios boilerplate |
| oaswrap/spec + orval | Code-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.