Advanced Typescript

September 17th, 2020  by  Arion Sprague

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(x: T): T { return x; } const val = identity("hello"); Since I’m passing in a string for x, Typescript infers that T is a string. Therefore the return value is string, and val is also a string. Generics also let us limit the the types that can be passed in. type Addable = string | number; function add(x: T, y: T): T { return x + y; } add(4, 5) === 9; add("hello ", "world") === "hello world"; add(true, "hello") // Won't accept boolean add(4, "hello") // And T must be consistent. Again, Typescript infers what T is. And it’s really good at inferring. Let’s extend add to allow an arbitrary number of arguments. It’s generic splat time. type Addable = string | number; function add(...args: T[]): T { return args.reduce((x, y) => x + y, 0); } add(4, 5, 6) === 9; add("hello", " ", "world") === "hello world"; This looks identical to our sum function, but it accepts more types. Typescript infers the types correctly. Using this function is as easy as JS, but still provides type safety. Tuple types Before we tackle memoize, we need to understand how tuples work in Typescript. A tuple is like a list, but it has a fixed length and fixed types. // Array const array: Array<string | number> = ["hello", 5]; const first = array[0]; // type is string | number, value = "hello" const second = array[1]; // type is string | number, value = 5 const third = array[2]; // type is string | number, value = undefined :( // Tuple const tuple: [string, number] = ["hello", 5]; const one = tuple[0]; // type is string, value = "hello" const two = tuple[1]; // type is number, value = 5 const three = tuple[2]; // Won't compile :) An array value can be either an array type or a tuple type. If you’ve used React’s useState, you’ve already used tuples. function MyComponent() { const [count, setCount] = useState(0); ... // more code here. } Typescript knows that val is a string and setCount is a (val: string) => void because useState returns a tuple. We can rewrite our add function from earlier using tuples. type Addable = string | number; function add(...args: [T, T]) { return args[0] + args[1]; } add(1); // Typescript error: Not enough args add(1, 2) === 3; add(1, 2, 3); // Typescript error: too many args I would never write a function like this directly, but tuples + splats is extremely powerful when used with type inference. Type inferred memoize Here’s the whole, working memoize function. It will take any function, infer the arguments (as a tuple) and the return type. It returns a new function with the exact same type signature. The key to all of this is in the Fn type defined below: interface Cache { [key: string]: any; } type Fn< TArgs extends unknown[], // This is the magic TResult

= (...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.

Share

Evernorth, the health services segment of Cigna Corporation, selects Hinge Health for Digital Health Formulary. Read Press Release