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)