Combining TypeScript assertion signatures and function overloads
TypeScript has a useful feature called assertion signatures that allows you to write functions that can be used to narrow the type of a value. For example, you can write a function that asserts that a value is a string and tells TypeScript that after this function has been called, that value must be a string.
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error("Expected string");
}
}
This is somewhat similar to type guards, but rather than being used in a condition, it lets TypeScript narrow the type in the current scope without introducing a new variable.
You can use an assertion signature like that to unify validation and type checking:
const value: unknown = getValue();
assertIsString(value);
// This is now safe both at run-time and at compile-time.
value.toUpperCase();
Assertion signatures are useful both in implementation code and in tests. In implementation code, they provide a quick way to unify run-time validation with compile-time type checking. They provide a similar benefit in tests by making it easier to write the rest of the test case with the benefit of the added type information.
This is straightforward for simple types and assertions, but it can get a bit trickier when we already have some useful type information available in the scope. With a naive assertion function, the resulting type may be less precise than expected because the assertion signature does not capture information that was already known about the value.
function assertArrayNotEmpty(
value: unknown,
): asserts value is [unknown, ...unknown[]] {
if (!Array.isArray(value) || value.length === 0) {
throw new Error("Expected non-empty array");
}
}
const mysteryValue: unknown = getValue();
assertArrayNotEmpty(mysteryValue);
So far, so good. After the assertion function call, TypeScript knows mysteryValue is of type
[unknown, ...unknown[]]. That is, an array with at least one element, but the element is of type
unknown.
What about if we already have some type information about the value and we would like to combine it with extra type information from an assertion signature?
function getValue(): ("foo" | "bar")[] {
// ...
}
const lessMysteriousValue = getValue();
assertArrayNotEmpty(lessMysteriousValue);
const first = lessMysteriousValue[0];
// The type of `first` is inferred as `unknown`
The assertion doesn’t replace the original type in a useful way here. TypeScript combines the
existing type with the asserted type, but because the asserted tuple type uses unknown for its
elements, indexed access on the refined value also gives unknown.
The assertion signature only describes [unknown, ...unknown[]], so it does not preserve the
existing knowledge that each element is either "foo" or "bar".
We can update our assertion function to incorporate existing information from the calling scope:
function assertArrayNotEmpty<TArray extends unknown[]>(
value: TArray,
): asserts value is TArray &
[TArray[number], ...TArray[number][]] {
if (!Array.isArray(value) || value.length === 0) {
throw new Error("Expected non-empty array");
}
}
That works fine, but we’ve had to enforce that only arrays can be passed to this assertion function. The original simpler assertion function accepts any input and then tells TypeScript about the type. That makes it flexible for callers, and easier to read.
We could make a single assertion signature more complex so that it handles both arbitrary inputs and already-known array types. For this simple example that would probably be fine, but assertion signatures can get a lot more complicated than this, and it starts to become difficult to read and maintain beyond a certain point.
We can solve that by splitting the assertion function into multiple overload signatures. That way, each possible signature is concise and readable.
// More complex overload to incorporate existing type
// information about array input.
export function assertArrayNotEmpty<TArray extends unknown[]>(
value: TArray,
): asserts value is TArray &
[TArray[number], ...TArray[number][]];
// Simpler signature that accepts any input.
export function assertArrayNotEmpty(
value: unknown,
): asserts value is [unknown, ...unknown[]];
// Actual run-time implementation that does not need to
// worry about assertion signatures.
export function assertArrayNotEmpty(
value: unknown,
): void {
if (!Array.isArray(value) || value.length === 0) {
throw new Error("Expected non-empty array");
}
}
The overloads should be ordered from most specific to most general, because TypeScript selects the first compatible overload signature.
With those overloads, we now get the best of both worlds. The assertion function is flexible and accepts any input. For callers that already have an array type, the generic overload preserves and refines the existing type information. For callers with an unknown value, the fallback overload still provides useful narrowing. This lets TypeScript narrow the type using the extra information, rather than falling back to a less precise result.
const mysteryValue: unknown = getMysteryValue();
assertArrayNotEmpty(mysteryValue);
// mysteryValue is now typed as [unknown, ...unknown[]].
const lessMysteriousValue: ("foo" | "bar")[] = getLessMysteriousValue();
assertArrayNotEmpty(lessMysteriousValue);
// lessMysteriousValue is now typed as:
// ("foo" | "bar")[] & [("foo" | "bar"), ...("foo" | "bar")[]]
const first = lessMysteriousValue[0];
// The type of `first` is inferred as "foo" | "bar"
For lessMysteriousValue: ("foo" | "bar")[], TypeScript is able to combine that type information
with the assertion signature from assertArrayNotEmpty to infer that lessMysteriousValue must be
an array with at least one element, where each element is either "foo" or "bar".
Function overloads let us express different type-narrowing scenarios without forcing all the complexity into a single assertion signature. Callers with little type information still get useful narrowing, while callers with richer existing types can preserve and refine that information rather than collapsing everything into a single generic assertion.
This is the approach taken internally in the
@kensio/smartass type narrowing assertion library.
Each assertion function can accept any input. Individual function overloads for each assertion
function define various assertion signatures to capture potential existing type information in the
calling scope. This results in straightforward readable types in IDE tooltips and TS compiler
errors.