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)