Skip to content
All articles

Where TypeScript Stops: The API Boundary From Both Sides

11 min read

Here is a line of code that appears in almost every TypeScript frontend I have worked on, including ones I wrote:

const user = (await response.json()) as User

From that line onward, the editor is confident. Autocomplete works. Renaming a field updates every usage. The build passes. And none of it means anything, because as User is not a check — it's an instruction to stop checking. response.json() returns any, TypeScript's types are erased before the code ever runs, and the only thing that assertion has established is that nobody will be warned when the server sends something else.

This is fine right up until the API is written in a language with different opinions about types than TypeScript has. I spend my days on exactly that seam — a typed frontend talking to a PHP commerce backend through a Node API layer — and the failures are consistent enough to be worth writing down.

The second half of this is about what changes when that untyped system is one you can put something in front of, because the answer stops being "validate harder" and starts being "move the boundary".

The four ways the types stop being true

Numbers arrive as strings. This is the classic one. A lot of PHP database access returns every column as a string unless it's been configured otherwise, and json_encode faithfully passes that along. So price is typed number, contains "19.99", and your total comes out as "19.99" + "4.50" — which JavaScript is happy to evaluate as "19.994.5" without raising so much as a warning. The interface said number. The runtime disagreed. Nothing caught it.

Empty arrays become empty objects, or the reverse. PHP has one array type doing the work of both a list and a dictionary, and json_encode guesses which one you meant from the keys. An empty PHP array encodes as []. The same array with one string key encodes as an object. So an endpoint returning a list of applied discounts sends [] when there are none and {"SAVE10": {...}} when there are — and your discounts.map() works perfectly in every test you wrote against a store with no discounts.

Missing and null are not the same, and both mean "absent". TypeScript makes a real distinction between field?: string and field: string | null. Most backends do not, and will cheerfully send you a key that's absent on Monday and null on Tuesday depending on which code path produced it. If your rendering does user.company.length, one of those days is an exception in production.

Dates are strings pretending otherwise. JSON has no date type. So createdAt is typed Date in the interface, is actually a string, and works for months because every place you touch it happens to be rendering it. Then someone sorts by it, or subtracts two of them, and the bug is three layers away from the cast that caused it.

Validate at the boundary, once

The fix is not to litter type guards through the application. It's to decide where untrusted data becomes trusted data, and make that one place actually earn it.

Concretely: no component, hook or store ever calls fetch and casts. Each resource gets one function that performs the request, checks the response against a schema at runtime, and returns a value whose type is derived from that schema rather than declared alongside it. Everything past that function can treat the data as genuinely typed, because something verified it. Everything before it is explicitly untrusted.

Whether you use a validation library or hand-write the parse functions matters much less than that the type and the check come from the same definition. The failure mode you're avoiding is an interface that says one thing and a validator that says another — at which point you have two contracts and no way to know which one production is following.

The boundary is also the right place to normalise. If the backend sends prices as strings and there's no realistic path to changing that, convert them there — once, visibly, in the layer whose job is to translate. What you must not do is let a numeric string through and handle it defensively in fifteen call sites, which is how you end up with Number(price) scattered across a codebase and no clarity about which fields need it.

Model what you consume, not what they store

There's a strong pull toward making your frontend types a faithful mirror of the backend's data model — same names, same nesting, same everything. It feels rigorous. It's usually a mistake, because it couples your UI to their schema and imports every historical oddity in it: the field that's named after a feature that was renamed two years ago, the flag that only means something in combination with another flag, the nested object that exists because of how it's stored rather than how it's used.

The boundary layer is where that translation should happen. Downstream code works with the shape the interface actually needs; the parse function absorbs the difference. When the backend renames a column, one file changes.

The practical test, which works in both directions: if a field name only makes sense to someone who has read the other system's database schema, it does not belong on your side of the boundary.

Errors are part of the contract

One more thing that reliably gets skipped: fetch does not reject on a 404 or a 500. It resolves, and then .json() either parses an error body into something that isn't the shape you expected, or throws on an HTML error page from a proxy that never reached your application at all.

So the same boundary function that validates the success shape has to check the status first, and needs a defined answer for a body that isn't JSON. Not because it's elegant, but because "the API returned a 502 and the page rendered undefined everywhere" is a genuinely common production bug with a genuinely boring fix.

When to move the boundary instead

Everything above assumes the untyped system is someone else's and you are stuck defending against it. Often you are. But there is a point where doing this in the client stops being the right answer, and it arrives sooner than people expect.

The signal is repetition. Most working software has a system at the centre of it that everyone needs and nobody wants to touch — it predates the current team, its schema was designed for its own admin interface rather than for anyone reading it, and it is load-bearing enough that replacing it is a project nobody will fund. And every new thing that needs its data reaches directly in. A sync job, a webhook consumer, a reporting service, someone's spreadsheet script. Each one independently works out authentication. Each one independently discovers that the "status" column holds a number whose meaning is documented in a constants file. Each one hardcodes that mapping. Then the platform changes, and you fix the same bug in six places, assuming you can find all six.

At that point the parse layer in your frontend is one of six copies of the same defence. The better move is to write it once, on the other side: a single service in front of the old system that everything else talks to instead. Eric Evans called this an anti-corruption layer. I built one over a CS-Cart commerce platform in Fastify and TypeScript, and the rest of this is what I would tell someone starting that job.

It is the same boundary. It has just moved to the side that only has to get it right once.

Do the unpleasant translation once, on purpose

The strongest temptation is to make the service a thin proxy: same field names, same shapes, just over HTTP with a nicer auth story. It's fast to build and it is the whole mistake, because it exports the legacy schema through your clean boundary and now everyone downstream depends on the thing you were insulating them from.

Every old system has accumulated representational oddities. Booleans stored as 'Y' and 'N'. Money as a float, or as a string, or in a different currency depending on a column three tables away. Timestamps as strings in local time with no zone. Status as an integer with a legend somewhere.

All of that is now your job, and doing it in one place is the entire value of the exercise. Booleans become booleans. Money becomes integer minor units with an explicit currency — the same discipline I'd apply anywhere money is involved. Timestamps become ISO 8601 with an offset. Statuses become a documented string enum.

The rule that keeps this honest: the translation is not allowed to leak. The moment one endpoint passes through a raw platform value "just for now", consumers start depending on it, and you have built a second legacy system with better syntax.

Authentication and error semantics, also once

Two more things every consumer would otherwise reimplement, both easy to get subtly wrong.

Handle authentication to the legacy platform inside the service, and give consumers a credential that is yours — one you can scope, rotate and revoke without anyone touching the old system's user table. If a consuming service is compromised, you want to be able to cut it off in one place.

Then error semantics. Legacy platforms are famously creative here: HTTP 200 with an error message in the body, empty responses for missing records, an exception page when a parameter is wrong. Normalise it. A missing record is a 404. A bad request is a 400 with a machine-readable reason. A platform failure is a 502 — because it genuinely is a bad gateway, and saying so tells the consumer whether retrying is sensible.

And when validation of the platform's own response fails, fail loudly. A malformed record is a real event someone needs to see, and swallowing it produces the worst outcome available: a consumer that silently receives less data than it asked for and cannot tell.

Write it down, or you become the API

This is the part that determines whether the project actually pays off, and it's the part most likely to be cut.

If integrating requires a conversation with you, you haven't removed the bottleneck — you've moved it from the platform's internals to your own head, which is worse, because at least the platform's internals were readable at 2am. The goal is that another team can go from nothing to a working integration without asking anyone how it works.

That means request and response examples for every endpoint, the error cases and what each one means, and the enum values written out. Tests are part of this too: a test suite is documentation that cannot go stale, and it is how you find out that a platform update changed a payload shape before a consumer finds out for you.

Resist scope creep into business logic

Once the boundary exists it becomes the obvious place to put things. Someone needs an endpoint that also applies a discount rule. Someone needs one that sends an email. It's right there and it already has authentication.

Be strict early. This service translates and validates; it does not decide. Once business rules live in it, it stops being a boundary you can reason about and becomes a second application with its own opinions about the domain — and now there are two places where an order's rules are implemented, which is exactly the condition you set out to eliminate.

What this actually buys you

On the consuming side: not correctness in the abstract, but something narrower and more useful. When the backend changes shape, you find out at the boundary, with a message naming the field, instead of three components deep with undefined is not an object.

On the providing side: platform-side changes stop being a scavenger hunt. When the old system alters a payload, one service breaks, its tests say so, and you fix it in one place. Every consumer keeps working against a contract that didn't move. That property is worth more than the time saved on any individual integration, and it's the thing you are actually buying.

TypeScript is excellent at keeping a codebase honest with itself. It cannot keep a codebase honest about someone else's server — that part is a runtime problem, and it needs a runtime answer. The only real question is which side of the wire you put it on, and the answer is whichever side has to get it right fewest times.

It's also the only version of "we should replace that legacy system" that has ever worked in my experience — not a rewrite, but a boundary drawn around it, so that the replacement, if it ever comes, is a change behind an interface rather than a change to everything at once.