177: Node and Express with TypeScript: Request Boundaries, DTOs, Services, and Error Contracts
Learning outcomes
By the end of this lesson, you can:
- explain and apply typed route declarations in a realistic implementation;
- explain and apply dto separation in a realistic implementation;
- explain and apply service interfaces in a realistic implementation;
- explain and apply error taxonomy in a realistic implementation;
- explain and apply request context in a realistic implementation.
Prerequisites and retrieval
This lesson builds on the 01–06 foundation and the earlier lessons in this module. Before you begin, retrieve one concrete example from a previous project in which one of these concerns appeared. You are not trying to memorize a list of terms. You are practicing how to make and defend a design decision in a strict TypeScript codebase.
TypeScript can help model domain invariants, but its static types do not validate data arriving over HTTP. That distinction is the foundation for everything that follows: use the compiler to make trusted code safer, and use runtime checks to make untrusted input trustworthy enough to enter that code.
Terminology
- Typed route declarations: Framework generic parameters improve the editor's contracts for params, body, query, and response values. They describe the shapes your code expects; they do not prove that a client sent those shapes.
- DTO separation: Keep HTTP DTOs separate from domain commands and persistence records when nullability, generated IDs, dates, authorization, or representation differ between those layers.
- Service interfaces: Application services should accept trusted domain commands and return stable results and errors. Their interface keeps domain behavior independent of Express.
- Error taxonomy: Define expected application errors, such as validation, not found, conflict, and forbidden, separately from unexpected faults.
- Request context: Model the authenticated principal, tenant, request ID, and transaction or request-scoped dependencies explicitly.
- Async handlers: Make sure rejected promises reach error handling as expected for the framework and runtime versions in use.
Mental model
Treat Node and Express with TypeScript: Request Boundaries, DTOs, Services, and Error Contracts as a design problem with observable inputs, outputs, invariants, and failure modes. Express generics are useful for documenting and navigating a route contract. They are not a security boundary, though. A production API must not trust a route parameter or request body merely because an Express type parameter says it has a particular shape.
A strong implementation makes assumptions visible, narrows uncertainty at the system boundaries, and leaves evidence that the design is safe. That evidence might be tests, types, database constraints, metrics, or diagrams. The point is not to use every mechanism; it is to be able to show why the important invariants hold.
A useful sequence for both production work and interviews is:
requirement -> constraints -> model -> implementation -> failure analysis -> verification
Do not jump from a requirement straight to a library call. First state what must remain true. Then choose the mechanism that enforces it and decide how you will observe a violation.
Deep dive
1. Typed route declarations
When a route declaration includes framework generic parameters, the editor can help you work with params, body, query, and response values consistently. Those parameters describe the expected shapes inside the handler. They do not parse strings, reject malformed JSON, or verify that a caller is authorized to use a value.
This is where people usually get confused: compile-time confidence begins only after data has crossed the runtime boundary safely. Every client-controlled field still needs runtime parsing or validation.
Decision rule: Use typed route declarations deliberately when they make a contract or invariant easier to prove. If they only reduce typing while hiding an assumption, prefer the more explicit design.
2. DTO separation
An HTTP payload, a domain command, and a database record often look similar at first. They still have different owners and different reasons to change. An HTTP DTO may omit a generated ID, accept a representation of a date, or expose fields that require authorization. A persistence record may contain nullable or generated fields that should never be accepted from a client.
Keep those DTOs separate from domain commands and persistence records when nullability, generated IDs, dates, authorization, or representation differ. Mapping between them makes those differences visible instead of allowing an accidental field to travel through the system.
Decision rule: Use DTO separation deliberately when it makes a contract or invariant easier to prove. If it only reduces typing while hiding an assumption, prefer the more explicit design.
3. Service interfaces
An application service is the place for a use case and its domain-level decisions, not for HTTP transport details. It should accept a trusted domain command and return a stable result or a classified error. Passing Express request or response objects into domain logic couples that logic to the framework and makes it harder to test, reuse, or run outside an HTTP request.
Decision rule: Use service interfaces deliberately when they make a contract or invariant easier to prove. If they only reduce typing while hiding an assumption, prefer the more explicit design.
4. Error taxonomy
Not every failure means the same thing to a caller. Invalid input, a missing resource, a conflicting write, and a forbidden operation are expected application outcomes. An unexpected database outage or programming error is a different class of failure and should not be disguised as one of those outcomes.
Define those expected errors separately from unexpected faults. Central error middleware can then map the known categories to stable HTTP responses while logging unexpected faults appropriately. That gives clients a useful contract without making the handler responsible for translating every error itself.
Decision rule: Use error taxonomy deliberately when it makes a contract or invariant easier to prove. If it only reduces typing while hiding an assumption, prefer the more explicit design.
5. Request context
Many operations need information that belongs to the current request: the authenticated principal, tenant, request ID, or a transaction and other request-scoped dependencies. Model that context explicitly and pass it through the application boundary that needs it.
Avoid ambient global mutable context. Concurrent requests can overlap, and a value stored globally can leak from one request into another. Explicit context makes ownership and testing clearer, and it gives logging and authorization code a reliable source of request-specific data.
Decision rule: Use request context deliberately when it makes a contract or invariant easier to prove. If it only reduces typing while hiding an assumption, prefer the more explicit design.
6. Async handlers
An asynchronous handler can reject after the handler has returned. The rejected promise must still reach the framework's error handling according to the Express and runtime versions you use. Verify that behavior rather than assuming all versions handle it identically.
Do not surround the whole handler with a broad try/catch that converts every failure into an indistinguishable 500 response. Catch only when you can add meaningful context, recover, or classify the error; otherwise let the established error pipeline handle it.
Decision rule: Use async handlers deliberately when they make a contract or invariant easier to prove. If they only reduce typing while hiding an assumption, prefer the more explicit design.
Worked example
Consider a strict TypeScript codebase that uses the compiler to model domain invariants without pretending that static types replace runtime validation. Start by writing the requirement in one sentence. Then list the input and output contracts and identify which of the concepts above owns each failure mode.
The key design move is separation. Parsing and validation belong at the boundary. Domain rules belong in the domain or service layer. Persistence rules belong in the database or repository. Presentation rules belong in the client. Combining all of these in one handler can make a happy-path demo shorter, but it makes edge cases and ownership much harder to reason about.
type TaskId = string & { readonly __brand: 'TaskId' };
function parseTaskId(value: unknown): TaskId {
if (typeof value !== 'string' || value.length === 0) {
throw new TypeError('Invalid task id');
}
return value as TaskId;
}
Here, unknown forces the boundary to justify the value before the rest of the program treats it as a task ID. The brand communicates that a parsed value has passed this check, although the check itself is intentionally small: it rejects a non-string or an empty string, but it does not prove that the task exists or that the caller may access it.
Walk through at least four cases:
- The normal path, where a valid ID reaches the service.
- An empty or missing value, where boundary parsing should reject the input.
- A duplicate, retry, or concurrent path, where the relevant service or persistence rule must decide what happens.
- A dependency failure, where the error should remain distinguishable from invalid client input.
For each case, state which layer detects the problem and what the caller observes. That is the level of reasoning expected in a senior code review or technical interview: not just whether the happy path runs, but whether each invariant has a clear owner.
Production perspective
Production correctness is broader than “the code works on my machine.” Consider deploys, retries, partial failure, stale clients, concurrent requests, malformed data, schema changes, and high-cardinality behavior. Prefer explicit contracts, bounded resource usage, structured errors, and measurable behavior. Optimize only after evidence identifies a bottleneck or a meaningful risk.
When an external dependency is involved, define a timeout and cancellation strategy. When persistence is involved, define transaction and consistency expectations. When the operation affects user-visible state, define loading, empty, error, stale, and success states. When security is involved, assume the client can be modified and all network input is untrusted.
Guided lab
Build one Express endpoint that takes unknown request data through runtime parsing, a typed domain command, a service interface, a repository, and a central error mapper. Add tests that bypass the TypeScript caller and send malformed JSON. This tests the actual HTTP boundary instead of only testing a caller that already satisfies the compile-time types.
Complete the lab with this discipline:
- Write the requirement and two non-requirements.
- List the input, output, and error contracts before implementation.
- Implement the smallest correct vertical slice.
- Add at least one invalid-input test and one edge-case test.
- Instrument or inspect the behavior instead of guessing.
- Refactor one hidden assumption into an explicit type, constraint, function, or configuration value.
- Explain one alternative design and why you did not choose it.
- Record a short “what would break at 10× scale?” note.
Edge cases and failure modes
Each concept needs more than a happy-path test. Check absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes.
- Typed route declarations: Verify that missing and malformed params, bodies, and queries are rejected at runtime rather than trusted from generic annotations.
- DTO separation: Check that omitted, generated, nullable, date, and unauthorized fields do not cross a layer accidentally.
- Service interfaces: Check invalid commands, repeated operations, concurrent operations, and dependency failures at the service boundary.
- Error taxonomy: Check that expected errors map to stable responses and unexpected faults are not misreported as client mistakes.
- Request context: Check missing or incorrect principal, tenant, request ID, and request-scoped dependency values, especially under concurrent requests.
Common mistakes and debugging
- Solving the example instead of the requirement: a copied pattern can be syntactically correct but architecturally wrong.
- Hiding uncertainty with assertions, broad exception handlers, permissive schemas, or “temporary”
anyvalues. - Testing only the happy path and therefore discovering contracts only after integration.
- Optimizing before measuring, or selecting a scalable mechanism without a scale requirement.
- Letting client-side behavior stand in for server-side authorization, validation, or persistence guarantees.
When debugging, reproduce the smallest failing case first. Inspect the actual value or execution plan, trace the boundary where the invariant first became false, and fix the layer that owns the problem instead of adding a downstream patch. Depending on the failure, that boundary may be the source or build, the HTTP request and route, the server handler, the database query, or deployment and configuration.
Interview questions
- What problem do typed route declarations solve, and what trade-off or failure mode would make you choose a different approach?
- What problem does DTO separation solve, and what trade-off or failure mode would make you choose a different approach?
- What problem do service interfaces solve, and what trade-off or failure mode would make you choose a different approach?
- What problem does error taxonomy solve, and what trade-off or failure mode would make you choose a different approach?
- What problem does request context solve, and what trade-off or failure mode would make you choose a different approach?
Checkpoint
Without notes, explain Node and Express with TypeScript: Request Boundaries, DTOs, Services, and Error Contracts to another developer in five minutes. Include one invariant, one edge case, one production failure mode, and one alternative design. Then implement a small example without copying the lesson code.
Mastery checklist
- I can define the core terms precisely.
- I can choose a design from requirements instead of from habit.
- I can implement and test the normal path and edge cases.
- I can explain the runtime, storage, or complexity cost.
- I can identify which layer owns validation, errors, and recovery.
- I can compare at least two reasonable alternatives.
- I can explain how the design changes at larger scale or stricter reliability.
