Advanced Typescript
Good lord, that title is dense. We’ll eventually use type inference to do all sorts of cool things with method signatures, but let’s start with the last two words: function splat. If you’re using ES6, you have access to the fantastic splat operator. It’s an easy way of letting a function have an arbitrary number of arguments:
function sum(...args) { // <= Splat!
return args.reduce((x, y) => x + y, 0);
}
sum(1, 5, 9) === 15
Splats offer an easy tool to make wrapper functions. For example, let’s say I wanted a generic memoize function:
function cacheKey(...args) { // good enough for demo
return args.map(String).join('.');
}
function memoize(fn) {
const cache = {}; // Saves the values
return (...args) => {
const key = cacheKey(...args);
if (!cache[key]) { // Not in the cache? Call fn.
cache[key] = fn(...args); // Now it's in the cache.
}
return cache[key];
}
}
Now I can easily wrap expensive functions in a cache.
const cachedFetch = memoize(fetch);
cachedFetch("/api/endpoint");
Subsequent calls to cachedFetch(“/api/endpoint”) will pull the value right from the cache.
NOTE: You probably don’t really want to use memoize for caching API calls. You miss out on cache invalidation, and other nice things. However, you can tweak the memoize code to work with a lot of other caching solutions (E.g. React’s upcoming createResource).
Typescript
That’s grand. Now let’s add Typescript. We’ll start with simple examples, then work our way to a generic memoize function which keeps track of fns argument types.
function sum(...args: number[]) { // args only allows numbers
return args.reduce((x, y) => x + y, 0);
}
sum(1, 5, 9) === 15
For sum, it’s really easy. args can only be of type number[]. We’re not taking a callback function, and we’re not returning a new function.
Generics
memoize is a lot more complicated. We’ll get to it in a moment, but we need to cover generics and tuples first. Generics are great. Similar to how we pass arguments into a function, we can pass types in too.
function identity
= (...args: TArgs) => TResult; // along with this. function cacheKey(...args: any[]) { return args.map(String).join('.'); } function memoize< TArgs extends unknown[], TResult (fn: Fn<TArgs, TResult>): Fn<TArgs, TResult> { const cache: Cache = {}; return (...args: TArgs) => {
const key = cacheKey(...args);
if (!cache[key]) {
cache[key] = fn(...args);
}
return cache[key];
} } const cachedFetch = memoize(fetch); cachedFetch("/api/endpoint"); // It knows this is a promise TArgs extends unknown[] will infer the most specific type it can. In this case, that’s fns arguments as a tuple. Since our return type is the same, our cachedFetch function just works. Using TArgs elsewhere Looking at the cacheKey function, I can think of plenty of cases where that breaks. memoize can’t know all of the use cases, so let’s take cacheKey as an argument. It should take the same arguments as fn, which we fortunately have. interface Cache { [key: string]: any; } type Fn< TArgs extends unknown[], TResult
= (...args: TArgs) => TResult; // New type type CacheKeyFn<TArgs extends unknown[]> = (...args: TArgs) => string function memoize< TArgs extends unknown[], TResult ( fn: Fn<TArgs, TResult>, cacheKey: CacheKeyFn
// Same TArgs ): Fn<TArgs, TResult> { const cache: Cache = {}; return (...args: TArgs) => {
const key = cacheKey(...args);
if (!cache[key]) {
cache[key] = fn(...args);
}
return cache[key];
} } Typescript will infer TArgs from fn, then enforce that cacheKey has the same signature. That’s great. Typescript’s generics allow us to write type-safe code that (mostly) feels like Javascript. The type definitions can be challenging at first, but inference is powerful enough to make using these functions feel almost like writing vanilla JS. And not having a user discover a cache collision (or other nasty bug) makes the extra overhead worth it.