Daniel's been writing TypeScript and wants the view from thirty thousand feet. Not syntax, not which library to pick — he wants the skeleton. What are the actual layers, how do they talk to each other, and what does the whole thing look like when it's assembled. So we're going to dissect a TypeScript server like a biology class frog, except the frog runs on Node and has types.
And the frog won't smell like formaldehyde, which is a nice upgrade.
Always a plus. Alright, let's trace this thing from the outside in.
So the mental model I use — and I think it holds up pretty well across most modern setups — is concentric circles. At the very core you've got the runtime. Node, Bun, Deno. That's the engine. It gives you the event loop, the file system, the network stack, the thing that actually executes JavaScript. Wrapped around that is your framework — Express, Fastify, NestJS, Hono. The framework gives you routing, middleware plumbing, request and response objects that aren't raw buffers. Then inside the framework sit your actual application layers: routing, middleware chains, services that hold business logic, and at the center, data access.
And TypeScript sits across all of this like... not a layer itself. More like a transparent overlay that's checking the seams between everything.
TypeScript doesn't add a new architectural layer. It adds a type system that makes every boundary between layers explicit. You can build this exact same stack in plain JavaScript — the architecture is identical. But with TypeScript, the contract between your service layer and your data layer isn't just a comment that says "this returns a User object." It's enforced by the compiler.
Which is the difference between a handshake and a notarized document.
Right. And when you're refactoring at two in the morning, the notarized document is the thing that saves you. So let's trace a request end to end. Real request: a POST to slash api slash users. Someone's signing up. What happens?
Walk me through it like I'm the packet.
Alright. The request hits your server on whatever port it's listening on — say port three thousand. At the lowest level, Node's built-in HTTP module parses the raw bytes off the socket into an HTTP method, headers, a URL, and a body stream. But almost nobody writes against the raw HTTP module directly anymore. You use a framework.
And what's the framework actually doing at that moment?
It's wrapping that raw request object and handing it to a middleware chain. Think of middleware as a conveyor belt. The request enters at one end, and each piece of middleware gets a chance to inspect it, modify it, or short-circuit it before it reaches the actual handler. So the first middleware might be CORS — it checks the origin header and adds the right response headers. The next might be a body parser. Raw HTTP gives you the body as a stream of bytes. The body parser middleware reads that stream, parses the JSON, and attaches the resulting object to request dot body. Then maybe an auth middleware runs — it checks the authorization header, validates a JWT, and attaches a user object to the request. By the time the request reaches your route handler, it's been enriched with parsed data and context.
And if any of those middleware steps fails, the request never reaches the handler.
Correct. The auth middleware sees an invalid token and just sends back a four-oh-one. Game over. The handler never runs. That's the beauty of the middleware pattern — it's a pipeline, and anything can stop the flow.
So now we're at the route handler. What's it actually supposed to do?
Ideally, almost nothing. A well-structured route handler is thin. It extracts parameters from the request — URL params, query strings, the parsed body — and hands them to a service. The service is where your business logic lives. And this is the boundary where TypeScript really earns its keep.
Say more about that boundary.
The route handler lives in the HTTP layer. It knows about requests and responses and status codes. The service lives in the business logic layer. It knows about users and orders and validation rules. It should not know it's being called from HTTP. You should be able to call the same service from a CLI script or a queue worker or a test suite. TypeScript enforces this separation. The service exports a function with a typed signature — createUser takes an email string, a password string, and a name string, and returns a Promise of User. The route handler calls that function. If the handler accidentally passes a number where a string is expected, the compiler catches it.
And the service doesn't know about request objects or response codes.
The service returns a User object or throws a domain-specific error like "email already exists." The route handler is responsible for translating that into HTTP — a two-oh-one with a JSON body, or a four-oh-nine conflict. The translation layer is thin, and it's the only place HTTP concepts exist. This is not a new idea — it's basically the "ports and adapters" pattern, or clean architecture, or hexagonal architecture. But TypeScript makes it practical to enforce without a massive framework.
Alright, so the service has validated the input, checked that the email isn't taken, hashed the password. Now it needs to actually store this user. What's the next layer?
Data access. This is typically a repository pattern or an ORM. The service calls something like userRepository dot create, passing in the validated data. The repository is responsible for translating that call into a database query. If you're using Prisma, it's generating the SQL for you. If you're using Drizzle, same thing but with a different API philosophy. The key point is that the service never writes SQL. It calls a typed function, and that function returns a typed object.
And this is where a typo in a column name becomes a compile error instead of a runtime crash at three AM.
That's the promise. In plain JavaScript, if you write user dot emial instead of user dot email, you get undefined, and that undefined propagates through your system until something explodes — probably in a template somewhere, rendering "undefined" to the user. In TypeScript, the compiler says "emial does not exist on type User." You fix it in ten seconds and move on.
I've spent entire afternoons chasing that exact bug.
Everyone has. That's why TypeScript adoption in the backend went from zero to basically default in about five years. The compiler is a tireless code reviewer that never gets bored and never misses a typo.
So we've traced the request. Middleware parsed and authenticated, router directed it to a handler, handler called a service, service validated and called a repository, repository wrote to the database. Now we reverse the path — the repository returns a User object, the service returns it to the handler, the handler serializes it to JSON and sets the status code, and the framework writes the response back down the socket.
And somewhere in there, the framework probably also ran response middleware — maybe a compression middleware that gzips the JSON before it hits the wire. Same conveyor belt, just going the other direction.
That's the anatomy. But you mentioned something earlier about dependency injection, and I want to dig into that, because it's the part that confused me the most when I first encountered it. How do these layers actually get wired together?
So in a simple Express app, you might just import the repository directly into the service file, and import the service directly into the route handler file. That works. It's simple. But it makes testing harder, because you can't easily swap the real database for an in-memory one. Dependency injection solves this by inverting the wiring. Instead of a service importing its dependencies, the dependencies are passed in — injected — from the outside.
So the service doesn't reach out and grab the repository. It's handed the repository when it's created.
In NestJS, which is the most popular DI-heavy framework in the TypeScript world, you define a service class, and in its constructor you say "I need a UserRepository." The framework's DI container looks at the type annotation, finds the thing that matches, and passes it in. At test time, you can override that binding and pass in a mock. The service code doesn't change at all.
And this works because TypeScript can emit metadata about constructor parameter types — the decorator and reflection metadata system.
Right. It's one of the few places where TypeScript's type system actually has a runtime effect, through the emitDecoratorMetadata compiler option. Most of the time, types are erased at compile time. But decorator metadata survives into the JavaScript output, and the DI container reads it at startup to figure out what to inject where.
Which is either elegant or horrifying, depending on your perspective.
I think it's both. It's a clever trick that makes the framework feel magical — until you have a circular dependency at startup and the error message is seventeen stack traces deep and none of them mention your actual code.
That's the sound of experience talking.
I've spent some quality time with NestJS error logs. But when it works, it's genuinely great. You get a clean separation of concerns, and every piece is independently testable.
Alright, let's zoom out. We've described the layers. But you said something earlier that I want to pull on — these layers are logical, not physical. They're all running in the same Node process.
Yes. And this is a really important point that trips people up. When you read about "layered architecture," you might picture separate servers — like a microservices setup where the service layer is one container and the data layer is another. But in a typical TypeScript backend, all of these layers are modules in the same process. The "boundary" between the service and the repository is a function call. It's not a network call. It's not a message queue. It's just one function calling another.
So why bother with the separation at all? If it's all in the same process, why not just write SQL in the route handler and be done with it?
Because the separation is about managing complexity over time. When your app has three endpoints, yeah, you can write SQL in the handler and it's fine. When it has three hundred endpoints and fifty database tables and business rules that interact in ways you didn't anticipate, the separation is what keeps you sane. The service layer is where you enforce invariants. "A user can't be deleted if they have outstanding orders." That rule lives in one place. If every handler talks directly to the database, that rule gets duplicated, or forgotten, or implemented slightly differently in different places.
So the layers are a discipline. They're not enforced by the runtime — they're enforced by the team agreeing to follow the pattern, and then TypeScript backs that agreement with compiler checks.
That's the best summary I've heard. The runtime doesn't care. Node will happily let you import the database connection in your route handler and run raw queries. Nothing stops you. The architecture is a convention, and TypeScript is the enforcement mechanism for the convention.
Let's talk about where this is going, because the ecosystem isn't standing still. You mentioned tRPC earlier, and that seems like it changes the game.
tRPC is one of those ideas that's so obvious in retrospect you wonder why it took so long. The problem it solves is the boundary between the backend and the frontend. In a traditional REST API, the backend exposes endpoints, and the frontend makes HTTP requests to those endpoints. The types are not shared. You define a User type on the backend, and then you define a User type on the frontend, and you hope they stay in sync. If you add a field to the backend User type, nothing tells you that the frontend type is now stale.
Until production, when something breaks.
Until production, yes. tRPC eliminates that gap. You define your API routes on the server, and tRPC infers the types. Then on the client, you import those inferred types. You don't write API client code at all. You just call a function — like trpc dot createUser dot mutate, passing in your data — and the types flow through. The function name, the input shape, the output shape — all type-safe, all the way from the database to the React component.
So the router definition becomes the single source of truth for the entire API contract.
And the compiler verifies the contract at both ends. If you change the input type on the server, the client won't compile until you update the call site. It's the same idea as the internal layers we talked about, but extended across the network boundary.
GraphQL codegen does something similar, right? You define a schema, and it generates types for both sides.
Similar goal, different approach. GraphQL has you define a schema in a DSL, and then codegen produces TypeScript types from that schema. tRPC skips the intermediate schema — the TypeScript types are the schema. It's a philosophical difference. GraphQL is language-agnostic by design; tRPC is aggressively TypeScript-first. If your whole stack is TypeScript, tRPC is less ceremony. If you need to support non-TypeScript clients, GraphQL makes more sense.
And this is where the runtime conversation gets interesting. You've got Bun and Deno coming up, both with built-in TypeScript support. How does that change the anatomy?
It removes a step. In a traditional Node setup, you write TypeScript, and then you have a build step — usually tsc or esbuild or swc — that compiles it to JavaScript before Node can run it. You need a dist folder, you need source maps for debugging, you need to configure your deployment to run the compiled output. Bun and Deno can run TypeScript directly. You point them at a dot ts file, and they execute it. No build step, no dist folder, no source maps.
Which sounds small, but in practice it eliminates a whole category of configuration and debugging.
It's huge for developer experience. You change a file, you hit save, you run it. That's it. The feedback loop is instant. And it also simplifies deployment — you're shipping the same files you wrote, not a compiled artifact that may or may not match your source.
But Node still dominates, right? Bun and Deno are exciting, but most production systems are still on Node.
Node is the safe choice, and it's not going anywhere. The ecosystem is enormous, every cloud platform supports it, and the performance is good enough for the vast majority of use cases. Bun is faster — sometimes dramatically faster — but speed is rarely the bottleneck in a typical backend. The database query takes a hundred milliseconds; shaving two milliseconds off the framework overhead doesn't move the needle.
Unless you're doing something that's CPU-bound in JavaScript, which most backends aren't.
Right. Most backends are I/O-bound. You're waiting on databases and external APIs. The runtime's event loop handles that efficiently regardless of whether it's Node or Bun or Deno.
So let's talk about the frameworks that are being built with TypeScript in mind from day one. You mentioned Hono and Elysia.
Hono is fascinating. It's a web framework that's designed to run on multiple runtimes — Node, Deno, Bun, Cloudflare Workers, even AWS Lambda. And it's built with TypeScript from the ground up. The API is designed so that types flow naturally from route definitions to handlers. You define a route with a path parameter, and the handler's parameter is automatically typed. You don't have to cast it or assert it.
So the type safety isn't bolted on after the fact — it's part of the framework's design.
And Elysia, which is Bun-native, takes this even further. It has a concept called "type strict" mode where the framework validates request bodies against TypeScript types at runtime. It's blurring the line between compile-time and runtime checking in a way that's new.
Wait — how does it use TypeScript types at runtime? I thought types were erased.
They are. But Elysia uses a pattern where you define your schemas using the framework's validation library, and the TypeScript types are inferred from those schemas. So the schema is the source of truth — it exists at runtime for validation, and the types exist at compile time for editor autocomplete and error checking. It's a clever inversion of the usual pattern.
So instead of defining a TypeScript type and hoping the runtime data matches, you define a runtime schema and let TypeScript infer the type.
That's the direction the ecosystem is moving. Zod is the most popular library for this — you define a Zod schema, and z dot infer gives you the TypeScript type. The schema validates at runtime; the type checks at compile time. Best of both worlds.
And that addresses one of the misconceptions you wanted to flag — that TypeScript guarantees runtime correctness.
It absolutely does not. TypeScript catches shape mismatches at compile time. It catches "you said this is a string but you're passing a number." It does not catch "this string looks like an email address but it's not." It does not catch "this number is negative but it should be positive." It does not catch "this user ID exists in the type system but not in the database." Those are runtime concerns, and they need runtime validation.
And that's where Zod or similar libraries come in. The type system handles structure; the schema library handles constraints.
The boundary between your system and the outside world — HTTP requests, database results, third-party API responses — those are the places where runtime validation matters most. Inside your own code, where you control the data flow, the compile-time checks are usually sufficient. But at the edges, you need to verify that the data actually conforms to your expectations, because the outside world does not care about your TypeScript types.
I want to circle back to something you touched on earlier — how TypeScript changes team dynamics. You said the type system becomes documentation.
It really does. When I was doing backend work in dynamic languages — Ruby, Python, plain JavaScript — the onboarding process for a new developer involved a lot of "here's the wiki, here's the API docs, here's the Postman collection, and by the way half of this is out of date." With a well-typed TypeScript codebase, the types are the documentation, and they can't go out of date because the build would fail.
You hover over a function in your editor and you see exactly what it expects and what it returns.
If you change the function signature, every call site lights up with red squiggles until you fix them. That's not just convenience — it changes how teams work. You can refactor with confidence. You can merge branches without worrying that someone added a required parameter to a function you're calling. The compiler does the integration testing for you.
Which also means you can move faster with a smaller team. The compiler is doing work that would otherwise require manual testing or a dedicated QA phase.
There's a tradeoff, though, and I want to be honest about it. Writing types takes time. Defining interfaces, writing generics, debugging type errors that span five files — it's real overhead, especially in the early stages of a project. If you're prototyping something that might get thrown away, strict TypeScript can slow you down.
But if it's going to production and you're going to maintain it for years, that upfront investment pays back.
In my experience, the break-even point is surprisingly early. Around the point where you have three or four developers touching the same codebase, or where the codebase is old enough that you've forgotten what you wrote six months ago. That's when the types start saving more time than they cost.
With AI coding assistants in the mix now, the equation shifts further. The AI can generate a lot of the boilerplate types, and it benefits from the type information when suggesting completions.
The AI is better at writing TypeScript than JavaScript, full stop. The type system constrains the space of possible completions, so the suggestions are more likely to be correct. And the types serve as a specification that the AI can read and understand. You define an interface, and the AI can implement it. That's a workflow that's becoming increasingly common.
Let's talk about the monorepo pattern, because that's another place where types change the architecture. You mentioned shared type packages.
In a system with multiple services — say you've got a user service, an order service, and a notification service — they all need to agree on the shape of the data they exchange. The traditional approach is to define API contracts in documentation, or use something like OpenAPI. The TypeScript approach is to put the shared types in a package — often called something like at-my-org slash types — and have every service depend on it.
If you add a field to the User type, every service that uses User sees the change immediately.
The build fails in every service that needs to be updated. It's the same principle as tRPC but applied across backend services rather than between backend and frontend. The types become the contract, and the monorepo tooling — things like Turborepo or Nx — makes it practical to manage the dependencies and the build order.
It's almost like the type system becomes the backbone of the architecture. The layers we described — HTTP, routing, services, data access — they're the organs. But the types are the skeleton that holds everything in place.
That's... actually a better metaphor than my concentric circles. The types are the skeleton. They define the shape of the thing, they provide structure, and when they break, everything collapses in a very visible way.
I have my moments. Alright, so we've covered the layers, we've traced a request, we've talked about how TypeScript enforces the boundaries, and we've looked at where the ecosystem is heading with tRPC, Bun, Hono, and shared type packages. I think we've answered Daniel's question about the anatomy. But I'm curious about one more thing — the frameworks that try to do everything. NestJS comes to mind. It's not just routing and middleware; it's a full architecture-in-a-box.
NestJS is opinionated in a way that Express is not. Express gives you routing and middleware and basically says "good luck." NestJS gives you modules, controllers, providers, guards, interceptors, pipes, filters — it has a name for everything, and a prescribed place for everything. It's heavily inspired by Angular, which is heavily inspired by Java Spring.
Which is either exactly what you want or exactly what you don't.
It depends on the project and the team. If you're building a large application with many developers, the structure that NestJS imposes can be a huge benefit. Everyone knows where things go. The DI container handles wiring. The module system enforces separation of concerns. But if you're building a small service with three endpoints, NestJS is a lot of ceremony for not much payoff.
The TypeScript integration is deep — decorators everywhere, generics in the provider definitions, the whole thing.
It's the most "enterprise" of the TypeScript frameworks. And that's not a criticism — enterprise patterns exist for a reason. They solve real problems at scale. But they also add complexity, and you should only pay for complexity you actually need.
The art is knowing which you need.
That's the whole game, isn't it? Picking the right level of abstraction for the problem at hand. TypeScript gives you the tools to enforce whatever architecture you choose, but it doesn't choose for you.
Before we wrap, Hilbert's been shifting in his chair for the last ten minutes. I think he's got something.
Hilbert: You're both right about the layers. I built something like that once. Nineteen ninety-eight. I was a systems integrator for a company called NetVantage. Dot-com era. We had Perl CGI scripts talking to an Oracle database. There were layers. We thought we were very sophisticated.
Perl CGI. That takes me back.
Hilbert: We had a single semicolon missing in one of the order processing scripts. Took the entire order system down for a weekend. Friday night through Monday morning. Nobody noticed until the phones started ringing and the customer service people couldn't pull up any orders from the last three days.
One semicolon.
Hilbert: One semicolon. The script just... stopped. Right in the middle of writing to the database. Half the order was there, half wasn't. We spent the whole weekend manually reconciling orders from email confirmations. I would have killed for a compiler that just told me where the problem was.
That's exactly the pitch. The compiler catches it before it ever reaches production.
Hilbert: I understand the pitch. I've used typed languages since. But I'll tell you something — I still don't fully trust them. The computer always finds a way to surprise you.
What do you mean?
Hilbert: About six years ago I was consulting for a logistics company. They had a TypeScript backend, all the types, all the tests, everything you're describing. Very proud of their architecture. One day the system started double-charging customers. Not every customer — about one in two hundred. Took us two weeks to find it.
What was the cause?
Hilbert: Race condition in a message queue consumer. The types were all correct. The function signatures were all correct. The compiler was perfectly happy. But two messages for the same order got processed simultaneously, and both of them committed the charge before either one checked if the order was already paid. The type system doesn't save you from that.
That's the runtime edge case you can't catch at compile time.
Hilbert: The types tell you the shape of the data. They don't tell you what happens when two copies of the same data arrive at the same time. That's a different kind of problem.
That's a really important point. TypeScript prevents a whole category of bugs — the shape-mismatch bugs, the "I passed a string where an object was expected" bugs. But it doesn't prevent logic errors, race conditions, or business rule violations. Those need a different set of tools.
Hilbert: The young developers I worked with at that logistics company, they'd gotten so used to the compiler catching everything that they'd stopped thinking about the runtime. The types compiled, so they assumed it was correct. That's the danger.
The compiler becomes a crutch.
Hilbert: A very good crutch. I'd still take it over Perl. But you have to remember that production is where the surprises live. The type system is a contract between you and the compiler. Production is a contract between you and reality, and reality doesn't read your type definitions.
Reality is untyped.
Hilbert: Reality is aggressively untyped. I had four of those logistics systems in a closet until last year. Kept them for parts. The hardware was worth more than the software by the end.
Of course you did.
Hilbert: The power supplies were very good. You can't get that model anymore.
I want to pull on that thread about developers trusting the compiler too much, because I think it connects to something we were saying earlier about the boundaries of the system. The type system is strongest inside your own code, and weakest at the edges — where data enters from the outside world.
Hilbert's race condition is a perfect example of a problem that lives entirely inside the system but still isn't a type error. Two concurrent database writes look perfectly fine to the type checker. The types say "this function writes an order" — and it does. The problem is that it should only write the order once, and that's a behavioral guarantee, not a type guarantee.
The takeaway isn't "types are useless." It's "types are necessary but not sufficient."
Types handle the structural correctness. You still need tests for the behavioral correctness, monitoring for the operational correctness, and — as Hilbert's story shows — careful thinking about concurrency and distributed systems.
Hilbert: You need someone on the team who's been burned before. Someone who looks at a message queue and says "what happens if this runs twice."
The scar tissue engineer.
Hilbert: Every team needs one. Ideally more than one. The compiler won't tell you what you haven't thought of. It only tells you when you've been inconsistent about the things you did think of.
That's a very clean way to put it. The compiler enforces consistency, not correctness.
Hilbert: I'm going to go home now.
That was Hilbert Flumingtop, who apparently has a closet full of decommissioned logistics hardware and a healthy distrust of compilers. And honestly, that's the right note to end the technical discussion on. Types are the skeleton — they give the system its shape and prevent a whole class of structural failures. But the system still has to function in a world that doesn't care about your interfaces.
The stack we described — the layers, the middleware chains, the services and repositories — that architecture exists to manage complexity. TypeScript makes the architecture explicit and enforceable. But the architecture is the real value. The types are just the tooling that makes it practical.
As the ecosystem keeps integrating types more deeply — tRPC, Hono, Elysia, the runtimes that swallow the build step — I think we're heading toward a world where the "layer" concept starts to blur. When types flow seamlessly from the database all the way to the frontend, the boundaries between layers become less like walls and more like seams in a single piece of fabric.
A type-safe continuum. Where the whole stack is one integrated system, and the types are the thread that runs through all of it. We're not there yet, but you can see the shape of it.
Thanks to our producer Hilbert Flumingtop for keeping us honest, and for the closet full of power supplies.
This has been My Weird Prompts. If you enjoyed this episode, tell someone who's still debugging a missing semicolon at three in the morning. You can find us at my weird prompts dot com.
We'll be back soon. Go build something with types.