Making Generic Functions Pass Type Checking
— by Gabriel Vergnaud · 5 min read · Jan 21, 2023
Let's say we have a function that adds 1
to either a string or a number:
function add1(value: string | number) {
if (typeof value === "string") return value + "1";
return value + 1;
}
This function is the cornerstone of our codebase and we absolutely need it to support both strings and numbers as arguments. We're pretty happy with it but its return type is a little unsatisfying:
const result1 = add1(10); // => 11
// ^? string | number
const result2 = add1("Number "); // => "Number 1"
// ^? string | number
Whether we give it a string or a number, we always get back a value of type string | number
.
This isn't great.
Anyone looking at the implementation of add1
would see right away that passing a string will always result in a string, and passing a number will always result in a number. TypeScript, however, doesn't seem to notice it!
Luckily, we recently learned about conditional types, a very nice feature of TypeScript that lets us write type-level code branching. They seem to be a pretty good fit for this problem, so we decide to give them a try:
type StringOrNumber<T> = T extends string ? string : number;
/* 👆
If `T` is assignable to `string`, return `string`.
Otherwise, return `number`. */
function add1<T extends string | number>(value: T): StringOrNumber<T> {
/* ... */
}
And it looks like it worked!
const result1 = add1(10);
// ^? number 💪
const result2 = add1("Number ");
// ^? string 😎
💡 Press the "Edit" button at the top-right corner of any code block to see type-checking in action.
Our function now has a much more precise return type! Hooray 🎉
But, as soon as we turn our eyes to the body of the function, our excitement starts to vanish:
type StringOrNumber<T> = T extends string ? string : number;
function add1<T extends string | number>(value: T): StringOrNumber<T> {
if (typeof value === "string") return value + "1";
// ~~~~~~~~~~~
// ❌
return value + 1;
// ~~~~~~~~~
// ❌
}
Oh no 🙀 New type errors!
Let's take a look at the first one:
Hum. TypeScript doesn't seem to be able to infer that value + "1"
is assignable to our return type — StringOrNumber<T>
. Why?
Well, TypeScript would need to understand that StringOrNumber<T>
should be reduced into string
because value
is of type string
. If it could infer this kind of conditional return type on its own, we probably wouldn't need to define StringOrNumber<T>
at all! It's because of this limitation that we had to write this type, so it kind of makes sense that TypeScript isn't able to check if our implementation is correct or not.
Let's take a look at the second error:
That's weird. TypeScript was able to look at the if
statement to narrow the type of value
to a number
a minute ago.
But something has changed. value
no longer has the type string | number
, but an unknown type parameter called T
.
When TypeScript sees a type parameter, it will pause any kind of type-level computation until this parameter gets assigned to a known type, like string
or number
. This includes type narrowing or evaluating conditional types, and this is the source of these new type-checking problems!
What can we do about it?
First, we need to acknowledge something painful: There is no type-safe way to solve this problem.
As soon as we use advanced type-level programming techniques like Conditional Types, Mapped Types or Recursive Types on type parameters, we are opting out of TypeScript's regular type inference mechanism. The type checker will no longer be able to make sure the implementation of our function does what it says.
When we use an advanced type signature, we choose to make the internals of our function less safe to make the code using it safer. This is a tradeoff worth taking for functions used in many places in our codebase!
So, how can we fix these nasty type errors?
Using as any
😕
as any
probably scares you a bit. You have good reasons to be afraid! This essentially tells TypeScript “Hush, don't bother me anymore!”. The type-checker will no longer try to catch our mistakes, so we shouldn't use it unless we really know what we are doing.
For our add1
function, we are fairly confident our code is safe, so let's try it out:
type StringOrNumber<T> = T extends string ? string : number;
function add1<T extends string | number>(value: T): StringOrNumber<T> {
if (typeof value === "string") return (value + "1") as any;
return (value + 1) as any;
// ~~~~~~~~~
// ❌ Still doesn't type-check 🤔
}
We got rid of the first type error, but not of the second one. TypeScript still complains that it can't use the +
operator on a value of type T
. We need yet another as any
:
...
return ((value as any) + 1) as any;
// 👆 👆
// ✅ This finally type-checks.
Our code finally type-checks, but it kind of sucks. The body of our function no longer has any kind of type-checking guarantees, it's cluttered with any
s and our IDE is no longer able to provide us with code suggestions.
You could object that using as number
and as StringOrNumber<T>
would have been preferable here, but this wouldn't have made our code any safer. In both cases, we essentially turn the type-checker off.
Do we have a better way?
Using Function Overloads 😁
Function overloads let us give several type signatures to a single function. They are often used to type functions that can take a variable number of arguments:
// Overloads
function assign<A, B>(a: A, b: B): A & B;
function assign<A, B, C>(a: A, b: B, c: C): A & B & C;
function assign<A, B, C, D>(a: A, b: B, c: C, d: D): A & B & C & D;
// the "real" function type
function assign(...objects: object[]): object {
return Object.assign({}, ...objects);
}
const result = assign({ a: 1 }, { b: "b" });
// ^? { a: number } & { b: string }
Function definitions without a body are called overloads. They are the only type definitions that are visible from the outside world. The body of the function, however, only knows about the "real" function type.
Function overloads are unsafe too. TypeScript will only perform some basic checks to see if all overloads have some sort of overlap with the actual function type, but it won't try to make sure they are correct.
But they perfectly solve our problem!
We can use a function overload to declare an external type and an internal type for our add1
function. For the outside world, we use a conditional types that ensures that the input and the output types stay in sync. For the internal code, we can just use the type we started with!
// External signature
type StringOrNumber<T> = T extends string ? string : number;
function add1<T extends string | number>(value: T): StringOrNumber<T>;
// Internal signature
function add1(value: string | number) {
if (typeof value === "string") return value + "1"; // ✅ type-checks
return value + 1; // ✅ type-checks
}
This is the best of both worlds:
const result1 = add1(10);
// ^? number
const result2 = add1("Number ");
// ^? string
We get precise type inference for our consumers while preserving some sort of type safety for the body of our function. Plus we don't need to use ugly as any
s anymore!
🤔 Why don't we use two overloads instead?
The add1
example is so simple that we could have defined 2 overloads instead of a conditional type to get the same result:
// External signatures
function add1(value: string): string;
function add1(value: number): number;
// Internal signature
function add1(value: string | number) {
if (typeof value === "string") return value + "1";
return value + 1;
}
This would probably have been better here, but as soon as our problems become more complex and we start needing more advanced features, using an external and an internal signature becomes extremely relevant.
I just wanted to illustrate this with the simplest problem possible! 😊
Summary 📚
When using advanced TypeScript patterns like Conditional Types, Mapped Types or Recursive Types, making the type-checker accept our code is sometimes challenging.
Function overloads are my favorite way to make my smart type definitions type-check. They are much better than as
because they preserve a decent level of type-safety for our implementation.
If you liked this article, chances are you'll like Type-Level Typescript. It's an advanced TypeScript course that will give you a solid understanding of the type system's fundamentals and guide you through its most advanced features. Enroll Now!
Subscribe to the newsletter!
Receive all new chapters and articles from Type-Level TypeScript directly in your inbox!