It is not uncommon to write guards in Javascript to prevent invocation of a function that could possibly be undefined or null:
if (someFunction) {
someFunction();
}
As a minor convenience, I usually write a function that will enact that guard for me:
function guardInvoke(func, ...args) {
if (func) {
func(args)
}
}
Then I would write invoke my functions like this: guardInvoke(myFunc, 1, 2, 3)
Moving to Typescript, we can still use the guard, but we should declare more types to make the function more type-safe. To do this I leverage some black magic types from the core Typescript lib.es54.d.ts
library
Diving into Parameters<T> and ReturnType<T>
Paramters<T>
: Obtain the parameters of a function type in a tuple
ReturnType<T>
: Obtain the return type of a function type
The type definitions are similar and interesting, but let's have a look at Parameters<T>
:
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
This can be pretty intimidating for the uninitiated, so let's break this down:
type Parameters<T extends (...args: any) => any>
All this means is T
must extend (...args: any) => any
- in other words T
must be any function. Why doesn't T
simply extend the Function
type? We are going to need a reference to ...args
in the next part.
T extends (...args: infer P) => any ? P : never;
This is a conditional type and will select one of two possible types based on the condition (just like a ternary!)
What is a bit unusual (at least when I first started looking at these) is that the condition appears to be gratuitous. Why do we need to evaluate the condition that T
is a function? We already constrained type T
to a function in the Parameters
declaration, the compiler would complain as soon as you attempt to type T
to anything but a function!
The answer is that while the evaluation of the condition will always result to true, conditional types can provide us type inference. The conditional type can include infer
to determine types when the conditional evaluation succeeds. So given the conditional type declares parameter ...args
with infer P
, we will return type P
when the condition evaluates to true (which it always will!) This is how Parameters<T>
identifies parameter types in functions.
ReturnType<T>
is very similar, but worth a look as well.
Where's that guard you were talking about?
Based on those core types, I was able to re-create my guard function with type-safety in Typescript:
/**
* Invokes a function if not undefined or null
* @param func - a possibly undefined or null function
* @param args - the function argument
* @returns The function's return, or undefined if function is undefined or null
*/
export function guardedInvoke<F extends ((...args: any[]) => any) | undefined | null>(
func: F,
...args: F extends ((...args: any[]) => any) ? Parameters<F> : any[]
): (F extends ((...args: any[]) => any) ? ReturnType<F> : never) | void {
if (func) {
return func(...args);
}
}
guardInvoke
will take a function argument, and make sure it isn't a bottom value before invoking it. Using the same black magic of conditional types with type inference leveraged by Parameters<T>
, we're able to get compiler safety and IDE hinting.
let addNumbers: ((...args: number[]) => number) | undefined;
let concat: ((arg1: string, arg2: string, arg3: string) => string) | undefined;
console.log(guardedInvoke(addNumbers, 1, 2)); // prints undefined
console.log(guardedInvoke(concat, "foo", "bar")); //prints undefined
addNumbers = (...args: number[]) =>
args.reduce((previous, current) => previous + current, 0);
concat = (a: string, b: string, c: string): string => `${a}${b}${c}`;
console.log(guardedInvoke(addNumbers, 1, 2, 3)); // prints 6
console.log(guardedInvoke(concat, "foo", "bar", "baz")); // prints "foobarbaz"
guardInvoke(addNumbers, "foo"); // ERROR
guardedInvoke(concat, 1, 2);// ERROR
UPDATE: Now that version 3.7 is available, optional calling is available to us via the new ?.
operator
addNumbers?.(1, 2, 3) // Will only be invoked if addNumbers exists