TypeScript & Go: picking the right tool per service
I write TypeScript and Go most days, almost never for the same service. The split isn’t ideology. It’s a boundary I drew once, watched hold up under real load, and stopped second-guessing. Here’s where the line falls and why.
Where TypeScript wins
Anything close to the product. The API types mirror the front-end, the domain models are rich, and the ecosystem already has a library for whatever you reach for. When the binding constraint is product iteration — and for a user-facing platform it almost always is — that combination ships a change in hours instead of days.
The reason is structural typing plus reach. The type that describes an API response can be the same type the React component consumes, end to end, with no translation layer to drift out of sync. And npm is the largest package registry going, so the build-versus-borrow decision usually lands on borrow. Neither of those matters for a batch worker. Both matter enormously for the surface a customer actually touches.
The cost is the one everyone knows: you’re standing on a runtime that will happily run undefined is not a function in production unless you keep the types honest. Strict mode, no implicit any, and a CI that fails on type errors are non-negotiable. With that discipline in place, TypeScript on the product surface is hard to beat.
Where Go wins
The hot paths: ingestion, fan-out, schedulers, anything CPU- or concurrency-bound. The properties that make Go boring here are exactly the ones you want at 3am.
- Predictable latency. The garbage collector targets sub-millisecond stop-the-world pauses (Go GC guide), so a tail-latency budget survives real traffic instead of being blown apart by a long pause.
- Concurrency that fits in your head. A goroutine starts with a few kilobytes of stack and is scheduled by the runtime (Go FAQ), so “one per unit of work” is a sane default rather than a resource gamble. A channel and a
selectcover most of what you’d otherwise pull in a framework to do. - Deploys that are a file copy.
go buildproduces a single static binary with no runtime to install. The artifact is a few megabytes, it starts cold in milliseconds, and shipping it is closer to copying a file than running an install.
None of this is free. You write more boilerplate, generics are newer and blunter than TypeScript’s, and the ecosystem, while solid, won’t hand you a niche integration the way npm will. On a hot path you’re trading developer reach for operational calm. On a hot path, that’s the right trade.
The boundary is the contract
Here’s the part the “use the right tool” advice skips: the interesting engineering isn’t in either language. It’s at the seam between them.
// same domain, two runtimes, one contract
ts: product API · admin tools · web
go: ingester · spam-scoring workers · schedulers
shared queue + a typed schema
The two sides never call each other. They meet at a queue and a schema. The product API drops a typed message; the Go workers pick it up, score it, and write results back through the same shape. Neither side imports the other, and neither cares what the other is written in. That decoupling is the whole point, and it’s what lets one side get rewritten, scaled, or restarted without the other noticing.
The schema is the thing you actually have to get right, because it’s the one piece both languages share. Define it once in a language-neutral format, generate types into both runtimes, and treat a change to it the way you’d treat a database migration: additive, versioned, backward-compatible until every consumer has moved. Get it wrong and you’ve turned two clean services into one distributed monolith that deploys in lockstep. Get it right and the language boundary becomes a non-event.
In practice
The user-safety work is exactly this shape. The product API is TypeScript: it’s where rules get authored, dashboards get rendered, and the schema changes most weeks as the product learns what abuse looks like. The market-data ingester and the spam-scoring workers are Go: they run hot, fan out across cores, and must not fall over when a burst arrives at the worst possible moment.
When a worker needs to handle more load, it scales out as more instances of a small binary, and the TypeScript side is none the wiser. When the product team needs a new field on a rule, it lands in the shared schema first, then both sides pick it up. The boundary has survived every one of those changes without a rewrite, which is the only evidence that matters.
The cost of running two languages
An honest accounting has to include the tax, because polyglot isn’t free:
- Two toolchains, two CI lanes, two sets of dependency upgrades. Every piece of build and release plumbing exists twice.
- Types live on both sides of the seam. The shared schema has to be generated into both runtimes, and that generation step is now load-bearing.
- Context-switching has a real cost. Moving between a
Promiseand a goroutine, betweennpmandgo mod, isn’t free even when you’re fluent in both. - Hiring and on-call get wider. Whoever carries the pager has to be comfortable in both stacks, or you run two rotations.
The split only pays off when the boundary is genuine: when the hot path is hot enough that Go’s properties earn their keep, and the product surface moves fast enough that TypeScript’s reach earns its own. Draw the line through the middle of a service that’s neither and you’ve bought every cost above and none of the benefit. The win isn’t using two languages. It’s having a boundary worth defending.
The rule
TypeScript for the parts that change often; Go for the parts that must not fall over. Draw the boundary once, put a versioned schema on it, and keep it honest. The language each side is written in should be the least interesting thing about the seam between them.