DEV Community

Cover image for TIL: get strongly typed HTTP headers with TypeScript
Tony Miller
Tony Miller

Posted on

TIL: get strongly typed HTTP headers with TypeScript

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;
}
Enter fullscreen mode Exit fullscreen mode

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];
    }
}

Enter fullscreen mode Exit fullscreen mode

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];
};

Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

That’s what VSCode thinks about StandardHeader:

Image description

Nice type literal with only known headers. Let’s plug it into getHeader(name: StandardHeader) and try to use it:

Image description

Auto-completion works and compilation breaks if we type something wrong there:

Image description

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];
    }
    
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

And it will auto-complete those headers:

Image description

And also it includes them into compile-time check:

Image description

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 =).

Oldest comments (0)