Goal
I’m a developer of a backend framework. It is written in TypeScript. I want to:
- Hide the actual request object (
http.IncomingMessage
) from my users - Yet provide my users with access to HTTP headers on the request (
http.IncomingHttpHeaders
). - Provide IntelliSense (auto-completion) so it is easier to find headers people want to use.
- Provide compile-time checking that there’s no type in a header.
- Do not limit my users as to which headers they can use, so the list of headers must be extensible from their services.
Turns out all of that is possible.
Implementation
Consider http.IncomingHttpHeaders
interface:
interface IncomingHttpHeaders {
'accept-patch'?: string;
'accept-ranges'?: string;
'accept'?: string;
…
'warning'?: string;
'www-authenticate'?: string;
[header: string]: string | string[] | undefined;
}
The problem with it that while it does have header names hardcoded it:
- does not provide a way to extend that list.
- provides index signature, which means all type safety goes out the window.
So in order to conceal the actual request from my users, I’ve got a class called Context
and I hand out instances of that to handlers for each request:
export class Context {
constructor(private req: http.IncomingMessage) { }
…
getHeader(name: ?) {
return req.headers[name];
}
}
…
What we want to do is to introduce some kind of type instead of ?
so that it allows only those headers from http.IncomingHttpHeaders
that are hard-coded, we will call them “known keys”.
We also want our users to be able to extend this list easily.
Problem 1
Can’t use simple type StandardHeaders = keyof http.IncomingHtppHeaders
because the interface has index signature, that resolves into StandardHeaders
accepting anything so auto-completion and compile-time checking doesn’t work.
Solution - remove index signature from the interface. TypeScript 4.1 and newer allows key re-mapping and TypeScript 2.8 and newer has Conditional Types. We only provide 4.1 version here:
type StandardHeaders = {
// copy every declared property from http.IncomingHttpHeaders
// but remove index signatures
[K in keyof http.IncomingHttpHeaders as string extends K
? never
: number extends K
? never
: K]: http.IncomingHttpHeaders[K];
};
That gives us copy of http.IncomingHttpHeaders
with index signatures removed.
It is based on the fact that ‘a’ extends string
is true
but string extends ’a’
is false
. Same for number
.
Now we can just:
type StandardHeader = keyof StandardHeaders;
That’s what VSCode thinks about StandardHeader
:
Nice type literal with only known headers. Let’s plug it into getHeader(name: StandardHeader)
and try to use it:
Auto-completion works and compilation breaks if we type something wrong there:
Problem 2.
We’re a framework, this set of headers is pretty narrow, so we need to give people ability to extend it.
This one is easier to solve that the previous one. Let’s make our Context
generic and add several things:
- limit generic to string type literals
- provide a sensible default
export class Context<TCustomHeader extends string = StandardHeader> {
constructor(private req: http.IncomingMessage) { }
…
getHeader(name: StandardHeader | TCustomHeader) {
return req.headers[name];
}
…
}
Ok, now our users can write something like this:
const ctx = new Context<'X-Foo' | 'X-Bar'>(...);
const foo = ctx.getHeader('X-Foo');
const bar = ctx.getHeader('X-Bar');
And it will auto-complete those headers:
And also it includes them into compile-time check:
Further improvements
Because we’re a framework, users won’t be creating instances of Context
class themselves, we’re handing those out. So instead we should introduce a class ContextHeaders
and replace getHeader(header: StandardHeader)
with generic method headers< TCustomHeader extends string = StandardHeader>: ContextHeaders<StandardHeader | TCustomHeader>
That is left as exercise for reader =).
Top comments (0)