DEV Community

Kevin Cox
Kevin Cox

Posted on • Originally published at kevincox.ca on

TypeScript Footgun — Unconstrained Generics

Let’s set the scene with some basic types. Our example is going to work with structured logging data.

interface Event {
    kind: string,
}

interface LoginEvent extends Event {
    kind: "login",
    effective: User,
    actual: User,
}

In this case we have made our Event extensible. We will assume that logs may be published by third party modules or services, so we can’t list them all in the source. We can assume that kind is unique across the system. There is a central registry and each kind will have a well-defined schema.

We are tasked to create function that takes a list of login event IDs, fetches them from some store, and counts how many are impersonation events. The code could look something like this:

export function countImpersonations(loginEventIds: EventId[]): number {
    let count = 0;
    for (const eventId of loginEventIds) {
        const event = fetchEvent(eventId);
        if (isImpersonation(event)) {
            count += 1;
        }
    }
    return count
}

Unfortunately there is a problem here. fetchEvent is a generic fetcher so returns Event, not the specific LoginEvent type that we expect. However, we know that the event is a LoginEvent as that is a precondition of this function. The simple solution would be using as.

isImpersonation(event as LoginEvent)

This works and isn’t too bad. But what if our caller messes up and passes up a non-login EventId? It would be better to have a guaranteed clear error instead of hoping that isImpersonation() crashes in an obvious way (or even worse succeeds and returns nonsense). A common solution to this is providing “checked cast” functions. For example:

function asLoginEvent(msg: Event): LoginEvent {
    if (msg.kind !== "login") {
        throw new Error(`Wrong event kind, expected login got ${msg.kind}`);
    }
    return msg as LoginEvent;
}

Now the code turns into the following. We make a quick check and abort with a clear error if something went wrong. Trust, but verify.

isImpersonation(asLoginEvent(event))

This is a pretty good solution but writing a custom helper for every kind of event is a bit tedious. So we may want to generalize it.

function asEventType<T extends Event>(msg: Event, expectedKind: string): T {
    if (msg.kind !== expectedKind) {
        throw new Error(`Wrong event kind, expected ${expectedKind} got ${msg.kind}`);
    }
    return msg as T;
}
isImpersonation(asEventType<LoginEvent>(event, "login"))

That looks pretty good. There is the obvious danger that we need to keep LoginEvent and "login" in-sync. However as that mapping is static this seems like acceptable risk.

However this function has a major footgun. If you don’t specify the generic parameter TypeScript will infer it, and it will be very generous.

isImpersonation(asEventType(event, "login"));
isImpersonation(asEventType(event, "Oh no!"));
printLogoutEvent(asEventType(event, "login"));

It turns out that the function has some similarities to returning any. This means that if one day someone refactors isImpersonation to use AuthTokenMintEvent rather than LoginEvent this code will still compile. Compiler-error drive refactors will lead to bugs because this code will silently keep working.

It is almost as if we wrote the following obviously dangerous code:

function asEventType(msg: Event, expectedKind: string): any {
    if (msg.kind !== expectedKind) {
        throw new Error(`Wrong event kind, expected ${expectedKind} got ${msg.kind}`);
    }
    return msg as any;
}

any is an incredibly dangerous tool. It basically disables all type checking with no warnings. Having any appear anywhere in your codebase is a huge risk. Avoid any at all costs.

This function isn’t quite as bad as returning any due to TypeScript’s limited inference, but it is close enough to cause trouble. I have seen many production bugs due to helper functions like this.

const loginEvent = asEventType(event, "login");
isImpersonation(loginEvent)
// error TS2345: Argument of type 'Event' is not assignable to parameter of type 'LoginEvent'.

Identifying the Problem

The key to this problem is that the generic parameter is not constrained by any arguments. The caller can pick any type and this function promises to output it. How can a function produce types that it doesn’t even know about?

function makeValue<T>(): T {
    return /* ??? */;
}

Solutions

Bind Generics to Parameters

Where possible this is the best solution. It ensures at compile time that the generic matches.

function asEventType<T extends Event>(msg: Event, expectedKind: T["kind"]): T {
    if (msg.kind !== expectedKind) {
        throw new Error(`Wrong event kind, expected ${expectedKind} got ${msg.kind}`);
    }
    return msg as T;
}

isImpersonation(asEventType(event, "login"));

isImpersonation(asEventType(event, "auth-token"));
// error TS2345: Argument of type '"auth-token"' is not assignable to parameter of type '"login"'.

Now you don’t even need to specify the parameter explicitly, TypeScript will infer the parameter and complain if the passed in kind string doesn’t match the expected value.

as Outside

For cases where you can’t strongly type the function my preferred solution is to move the as into the caller. Along with any, as is a key danger of TypeScript. While you will see it in most codebases it is important to use it as safely as possible. Whenever I use as I ask “Can any code changes break this assumption?”. If so then it is best to add more checks or avoid as. Using as to cast to a generic parameter is frequently a fragile assumption. So in this case it makes sense to remove it.

function asEventType(msg: Event, expectedKind: string): Event {
    if (msg.kind !== expectedKind) {
        throw new Error(`Wrong event kind, expected ${expectedKind} got ${msg.kind}`);
    }
    return msg;
}

isImpersonation(asEventType(event, "login") as LoginEvent);

The key difference to this code is that the as is at the call site. Since the call site is asserting that "login" matches LoginEvent it makes sense to put the dangerous operator there. If the as is forgotten the code fails to compile because Event can’t be passed where a LoginEvent is expected. If isImpersonation() is refactored to use AuthTokenMintEvent this code will also raise an error as it will continue to evaluate to LoginEvent. Then the problem can be addressed before a bug is ever committed.

Top comments (0)