Our frontend API types are generated from the backend's OpenAPI (Swagger) spec with swagger-typescript-api. With one project, that was a ten-line script: fetch the spec, write the file, done. Then we had more frontend projects, each with its own copy of that script, each a little different. Nobody could tell which copy had which fix.
Branches were the real headache, though. When the backend builds a feature, it works on a feature branch, and the frontend needs types from that branch, not from dev. That raises two questions:
- How does the frontend know which branch's spec to fetch?
- What about the gap between the feature branch and
dev?
The second one is sneakier. Once a backend feature branch is cut, it usually isn't rebased onto dev very often. Meanwhile other features land in dev along with their APIs, and none of those exist in your feature branch's spec. The frontend, on the other hand, rebases onto dev all the time. The moment you do, you pull in code from other features that already calls those new APIs. Now your code uses endpoints that the spec you're generating types from has never heard of, and typecheck fails. You didn't break anything, but CI is red.
backend dev ──o──────────●────────────── ● other branch merges API X
backend ft-coupon-v2 └──o──────────o────────── no rebase: spec has no API X
frontend dev ──o──────────────●────────── ● code that calls API X
frontend ft-coupon-v2 └──o─────────────●─────── rebased onto dev: now calls API X
│
types generated from backend ft-coupon-v2 spec
→ API X missing → typecheck fails
So I built an internal CLI package that owns the whole "which spec, how to merge it, where to write it" job. Nine frontend projects use it today, and several teammates have added features and fixed bugs in it since. This post walks through how it works and the traps we hit merging two OpenAPI specs. That second part applies no matter what generator you use.
The core idea: same-named branches
The answer to question one is a naming rule: the frontend branch uses the same name as the backend branch. If the backend opens ft-coupon-v2, so does the frontend.
That means there's nothing to configure. The branch you're on already tells the tool which spec you want. Switch branches and your types follow. CI runs the same logic. It also makes conversations easier: when someone says ft-coupon-v2, frontend and backend mean the same feature.
A naming rule alone isn't enough, though. The frontend also needs to know whether the backend actually has a spec for that branch. So I asked the backend team for one more endpoint: a list of branches that currently have a deployed spec. Before generating, the tool calls it and looks for its own branch name:
-
Found: fetch that branch's spec and merge it with
dev's (more on the merge below). - Not found: this branch only exists on the frontend side, so fall back to the staging spec.
current branch ──> branch-list endpoint ──> same name exists?
│ yes ──> merge dev + branch spec ──┐
│ no ──> use staging spec ─────────┴──> generate types
Nobody has to remember to point a config at the right branch anymore. Without this, it's easy to forget to switch it back and build production with feature-branch types, or to keep fetching a spec for a backend branch that was merged and deleted last week. Now the lookup decides.
The same mapping applies at runtime. Every backend feature branch is deployed to its own endpoint, so by default the frontend calls the endpoint matching its branch, and the types and the backend you're talking to come from the same source. If you want a different backend (say you're on a feature branch but want to test against staging data), you set an environment variable. If it's set, it wins. If not, the tool silently uses the feature branch's endpoint. Day to day, you set nothing.
The basics: one config file, one command
Each project has one config file at its root listing which docs to generate and where:
import { genConfig, API_DOC_NAME } from '@team/fe-manifest';
export default genConfig({
outputDir: 'src/swagger',
docs: [
{ doc: API_DOC_NAME.Public_API, outputFileName: 'public.api.ts' },
{ doc: API_DOC_NAME.Member_API, outputFileName: 'member.api.ts' },
],
});
Then npx hc-manifest generates the typed clients. The genConfig wrapper is there so the config file itself is type-checked: misspell a doc name and your editor tells you.
To get the current branch name, the tool checks, in order:
- A
--branchflag - The current git branch
- CI environment variables:
GITHUB_HEAD_REF,GITHUB_REF_NAME, EAS Build'sEAS_BUILD_GIT_COMMIT_BRANCH, and so on, first one with a value wins
One gotcha: CI often checks out a commit SHA, and then git rev-parse --abbrev-ref HEAD just returns HEAD. Treat that as "no answer" and keep going to the environment variables, or you'll end up looking for a branch literally named HEAD.
Once it has a name, it checks it against the backend's branch list and falls back to staging if there's no match. The generated file's header also records which branch it came from and when the spec was last updated. If neither has changed on the next run, generation is skipped.
Merging two specs: don't just deep merge
Back to question two. On a feature branch (ours start with ft-), the tool fetches both the dev spec and the feature branch spec, uses dev as the base, and lays the feature branch on top. New or changed APIs from the feature branch win, and everything other people added to dev stays.
My first version used lodash.merge. Seems reasonable, until you remember how it handles arrays: index by index. OpenAPI is full of arrays, and that produced two quiet bugs. Credit where it's due: teammates caught and fixed both of these in real projects.
Bug 1: parameters merged into each other. Say an operation in dev has four parameters and the fourth is a header. The feature branch puts a new boolean query parameter in that same slot. Merge by index and those two unrelated parameters get blended into one. The boolean param came out typed as string.
The fix is to merge parameters by identity (name + in), not position:
const mergeParamsByIdentity = (devParams: Param[], ftParams: Param[]) => {
const key = (p: Param) => `${p.name}::${p.in}`;
const ftKeys = new Set(ftParams.map(key));
// feature branch params win; dev-only params are appended
return [...ftParams, ...devParams.filter((p) => !ftKeys.has(key(p)))];
};
Bug 2: an enum lost a value. An enum in dev had nine values. The feature branch was cut earlier and was missing one in the middle, so it had eight. Merged by index, everything after the gap shifted up by one and the last value showed up twice. The generated enum was quietly one value short, the frontend's typecheck broke, and nothing warned us. With a different ordering you can even get a combination that neither branch ever declared. required arrays have the same problem.
The fix is lodash.mergeWith with a customizer that decides how arrays merge:
const mergeArrayCustomizer = (devValue: unknown, ftValue: unknown) => {
if (!Array.isArray(devValue) || !Array.isArray(ftValue)) return undefined;
// plain-value arrays like enum and required: take the union so nothing is lost
if (isPureValueArray(devValue) && isPureValueArray(ftValue)) {
return [...new Set([...devValue, ...ftValue])];
}
// arrays of objects or $refs: take the feature branch's array whole
return ftValue;
};
const merged = mergeWith({}, devSpec, ftSpec, mergeArrayCustomizer);
// then re-merge each operation's parameters with mergeParamsByIdentity
The union has a side effect: if the feature branch removed an enum value on purpose, the union puts it back. The tool can't tell "not synced yet" from "deliberately removed", so I added a warning. Whenever the union keeps values the feature branch doesn't have, it prints the path and the values and lets a human decide. At least it's a visible trade-off now instead of a silent bug.
One more edge case: if the feature branch explicitly says parameters: [] instead of omitting it, the "take the feature branch's object array whole" rule wipes out all of dev's parameters. So the parameter re-merge has to run when either side has parameters, not only when both do.
Other traps
operationIds with auto-added suffixes. To keep operationId unique, the backend's Swagger generator appends _1, _2 to duplicates. But it checks across the whole spec, so two operations under different tags still get suffixed. swagger-typescript-api generates one class per tag, so the frontend ended up calling cancelSubmission1() instead of cancelSubmission().
So before generating, the tool strips the suffix, but only when no other operation in the same tag already uses that name. Otherwise you'd get two methods with the same name in one class:
const match = op.operationId.match(/^(.+)_\d+$/);
if (match) {
const base = match[1];
const sameTagConflict = allOps.some(
(other) => other.operationId === base && other.tags.some((t) => op.tags.includes(t)),
);
if (!sameTagConflict) op.operationId = base;
}
null vs undefined. The backend returns null for empty fields, so lots of generated types are T | null. Most frontend code treats those fields as optional (T | undefined), and the two never lined up. We handled it in two layers:
-
Runtime: an axios response interceptor that strips
nullor turns it intoundefined, and re-types each API's response to match. -
Generation: a teammate added an option so the generated types are
T | undefinedfrom the start.
The two have to go together. Types that say undefined while runtime still hands you null is worse than either alone. So the tool now also checks that the project has the interceptor wired up when it generates types.
Rewriting files when only the timestamp changed. The generated file's header includes the spec's update time, so any redeploy of the spec rewrote the file even when nothing else changed, filling git diffs with noise. Now it compares content with the timestamp ignored and only writes when something real changed.
Results and takeaways
All nine frontend projects, Next.js sites and React Native apps, now generate types with the same command. Locally, it picks the right spec from your current branch. CI runs the same command, so if the backend API and the frontend's usage disagree, typecheck catches it before it ships.
What I took away from this:
- Let a naming convention replace configuration. Same-named branches plus one branch-list endpoint turned "which spec do I need?" into something the tool works out, not something people have to remember.
-
Don't merge OpenAPI specs with a generic deep merge. Index-based array merging is fine for most config objects but causes subtle bugs in schemas. Merge parameters by
name+in, and unionenumandrequired. - Silent errors are worse than loud ones. A bad merge doesn't throw. It just produces a type that looks plausible. Wherever there's a trade-off (like the union bringing back a removed value), print a warning so people can see it.
- Write tests from real incidents. For each merge bug, we turned the actual spec fragment that broke into a test and made sure it failed before the fix. That way nobody quietly undoes it later.
- Shared tools work best when everyone can change them. I started this tool, but many of the important fixes came from teammates who hit problems in their own projects. The biggest win of pulling the scripts into one package: one person fixes it, nine projects get the fix.
Top comments (1)
The spec merging approach is interesting. One thing I'd be careful about is the merged types describing endpoints that the actual feature-branch backend doesn't expose yet. TypeScript might be happy while the request still fails at runtime. Do you also run contract tests against the deployed branch endpoint?