TypeScript
Completed
-
Section 1: Getting Started
-
Section 2: Basic Types
-
Section 3: Functions and Objects
-
Section 4: Advanced Types
-
Section 5: Object-Oriented TypeScript
-
Section 6: Working with Modules
-
Section 7: Tooling and Practice
-
Section 8: Type-Level Programming
-
Section 9: TypeScript with Backends
-
Section 10: Testing Typed Code
-
Section 11: Data Validation with Types
-
Section 12: Practical Projects
-
Section 13: Compiler Internals
-
Section 14: Configuration Deep Dive
-
Section 15: Enums, Symbols, and Special Types
-
Section 16: Working with Async Code
-
Section 17: TypeScript and the DOM
-
Section 18: Advanced Generics Practice
-
Section 19: Working with Third-Party Types
-
Section 20: Monorepo and Large-Scale Practices
-
Section 21: Common Pitfalls and Best Practices
-
Section 22: Interview Practice
-
Section 23: Handbook: Narrowing In Depth
-
Section 24: Handbook: Object Types In Depth
-
Section 25: Handbook: Classes In Depth
-
Section 26: Handbook: Modules In Depth
-
Section 27: Handbook: Declaration Files In Depth
-
Section 28: JSX and Namespaces
-
Section 29: Compiler Configuration Reference
-
Section 30: More Practice Exercises
-
Section 31: Handbook: Everyday Types Deep Dive
-
Section 32: Utility Types Full Reference
-
Section 33: Decorators Reference
-
Section 34: Mixins and Advanced OOP Patterns
-
Section 35: Iterators and Generators Typing
-
Section 36: More Type-Level Programming Practice
-
Section 37: TypeScript Ecosystem Tools
-
Section 38: TypeScript with Testing Frameworks
-
Section 39: TypeScript for Library Authors
-
Section 40: More Interview Practice
-
Section 41: Handbook: Functions In Depth
-
Section 42: Handbook: Type Manipulation Deep Dive
-
Section 43: More Real-World Patterns
-
Section 44: TypeScript Release Notes Highlights
-
Section 45: Final Practice and Review
237: Typing a Middleware Chain
Middleware is a pattern we see everywhere—Express, Koa, Redux, and even some internal pipeline architectures. The goal is usually simple: take a request or a context object, do something to it, and pass it to the next function. But when you try to type this in TypeScript, you quickly run into a wall. Specifically, the "Wall of Missing Properties."
The Naive Approach
Let's say I'm building a small request pipeline. I have a basic RequestContext, and I want a series of middleware functions to enrich it. First, I'll extract a userId from a header, and then I'll fetch the userProfile based on that ID. Here is how I'd start if I wasn't thinking about the types too hard:
type RequestContext = { url: string };
type Middleware = (ctx: RequestContext) => RequestContext;
const authMiddleware: Middleware = (ctx) => {
return { ...ctx, userId: 'user_123' };
};
const profileMiddleware: Middleware = (ctx) => {
// Error: Property 'userId' does not exist on type 'RequestContext'
console.log(ctx.userId);
return { ...ctx, profile: { name: 'Alice' } };
};
Right there, TypeScript hits me. From the perspective of profileMiddleware, the ctx is just a RequestContext. It has no idea that authMiddleware just added a userId. I could use any or a type assertion (ctx as any), but that defeats the whole purpose of using TypeScript, doesn't it? We'd be flying blind.
Trying to Solve it with Generics
I want the output type of the first middleware to be the input type of the second. This smells like generics. Let's try to redefine our middleware to be flexible about what it receives and what it returns.
type Middleware<TIn, TOut> = (ctx: TIn) => TOut;
const authMiddleware: Middleware<RequestContext, RequestContext & { userId: string }> = (ctx) => {
return { ...ctx, userId: 'user_123' };
};
const profileMiddleware: Middleware<RequestContext & { userId: string }, RequestContext & { userId: string, profile: any }> = (ctx) => {
return { ...ctx, profile: { name: 'Alice' } };
};
This works! profileMiddleware now knows exactly what ctx contains. But look at those type definitions. They are hideous. If I change the userId to a number, I have to manually update every single middleware downstream in the chain. This isn't a scalable solution; it's just manual bookkeeping with extra steps.
Building a Type-Safe Compose Function
The real magic happens when we create a compose function. I want to pass a list of middleware and have TypeScript "calculate" the resulting type as it moves through the array. This is tricky because TypeScript arrays usually expect all elements to be of the same type, but our middleware types are evolving.
I'll use a recursive type or a series of overloads to handle this. For a small number of middleware, overloads are the most readable way to achieve this "piping" effect:
function compose<T1, T2, T3, T4>(
m1: Middleware<T1, T2>,
m2: Middleware<T2, T3>,
m3: Middleware<T3, T4>
): Middleware<T1, T4> {
return (ctx) => m3(m2(m1(ctx)));
}
// Now let's see if the types flow naturally
const pipeline = compose(
authMiddleware,
profileMiddleware,
(ctx) => {
// TypeScript now knows 'ctx' has userId AND profile!
return { ...ctx, timestamp: Date.now() };
}
);
const finalCtx = pipeline({ url: '/home' });
console.log(finalCtx.profile.name); // Fully typed!
I've just shifted the burden. Instead of typing every single middleware function explicitly, I'm letting the compose function infer the types. Because authMiddleware returns RequestContext & { userId: string }, TypeScript realizes that m2 (the profileMiddleware) must accept that exact type. It's a domino effect of type inference.
Dealing with the "Any" Leak
One thing I noticed while testing this is that if I accidentally pass a middleware that doesn't match the previous one's output, the error messages can get... weird. You'll see things like Type 'X' is not assignable to type 'Y', but the error might be pointed at the compose function rather than the specific middleware that broke the chain.
My advice here? Keep your middleware functions small and pure. The easier it is for TypeScript to see the return value of a single function, the easier it is for the compose chain to track the state. If you find yourself fighting the types, try extracting the logic into a named function with an explicit return type before passing it into compose.
📋 Practical Task
Exercise: Implementing a Data Validation Pipeline
You are building a data processing pipeline for a user registration form. The data starts as a raw object from an HTTP request and needs to be transformed through three stages: Sanitization, Validation, and Enrichment.
Your task is to implement a type-safe pipeline using the compose pattern discussed in the lesson.
- Stage 1 (Sanitize): Takes
{ email: string }and returns it as{ email: string, sanitized: true }. - Stage 2 (Validate): Takes the sanitized object and adds a
isValid: booleanproperty based on whether the email contains an '@' symbol. - Stage 3 (Enrich): Takes the validated object and adds a
tenantId: stringproperty (use a hardcoded value like 'tenant_01').
Requirements:
- Define a
Middleware<TIn, TOut>type. - Implement a
composefunction that handles these three specific stages. - Ensure that the final result of the pipeline is fully typed, meaning you can access
.sanitized,.isValid, and.tenantIdon the final object without any type casting.
There are no comments for now.