Supercharge your functions with Typescript generics and built-in types

Follow these tips to leverage the power of TypeScript and make better TypeScript functions!
6 min read
Anthony Antoine
Supercharge your functions with Typescript generics and built-in types

Whatever web framework you've been using has plenty of them; whether they're called hooks, composables, or anything else, they're all just functions for you to use. Some of those come with the framework, some others with additional packages you add to your project, and, most importantly, some you will write yourself.

If you examine the return values of Nuxt's built-in composables, you'll notice that they're quite good at inferring return types from their arguments, provided you typed those arguments.

type Human = {   name: string   age: number }  const tom: Human = {   name: 'Tom',   age: 47, }  export const myComposable = () => {   const populatedState = useState(() => [tom])    const addHuman = (human: Human) => {     populatedState.value = [...populatedState.value, human]   }    return {     addHuman,     populatedState,   } }
useState allows shared state in Nuxt

When you hover over the populatedState variable in your code editor or IDE, TypeScript can infer that it's an array of Human's wrapped in a ref.

There will be times when there are no types to infer due to ambiguous arguments, as in the following.

const unpopulatedState = useState(() => [])
Example with "never" inferred type

Hover unpopulatedState, and you'll see that the type for it is Ref<never[]>.

Returning this from your composable as is would be useless in terms of types because you would have no information about the array, even after adding to it. Fortunately, because useState makes use of generics, there is a simple workaround.

unpopulatedState is of type Ref<Human[]>

If you didn't already know, generics are a way for developers to use types as arguments for other types or functions to make them much more flexible. By passing Human[] to useState, even when empty, unpopulatedState will have the correct type.

If you were to create your own hook / composable, you would obviously want it to be as flexible and consistent as the built-in ones. For the sake of the example, let's create a usePick that has only one job: to return a stripped version of a reactive object passed to it, including only the fields passed as second arguments. This will be based on another "pick" function that you can use elsewhere in your code.

This is what that pick function would look like in plain JS.

const pick = (original, ...keys) => {   return Object.keys(original).reduce((acc, key) => {     if (key in keys) {       return {         ...acc,         [key]: original[key],       }     }     return acc   }, {}) }
Sad JS function 😞

Because there is no TS in there, you will not benefit from this pick function; it will simply do its job and then return to a life of mediocrity. But what if we really wanted that TS goodness? We could have an inferred type for "justAge" that would warn us if we tried to access another key on the object, and even if one of the strings passed to pick is not a key on the "original" argument.

I'll demonstrate how I'd implement the same function in TypeScript, and then we'll go over the specifics.

Superior TS function 😤

There's a lot to unpack here, so let's go over it piece by piece.

const pick = <T extends {}, K extends keyof T>(original: T, keys: K[])

T & K are the generic types here, consider them as another set of arguments, strictly for typing. We need two types for our pick function, which we'll use in the body of the function: T is the type of the original argument, extending {} will allow TypeScript to later know original behaves like an object, and thus can, in this instance, be passed to Object.keys(). K extends keyof T simply creates a second type from the keys of T, making it so that the strings in the array keys must be present on object original.

return {         ...acc,         [key]: original[key as K],       }

Object.keys() does not know the specifics of our T type, it returns an array of strings when given a valid object. This is why, when simply accessing values of original with key, you will get a TS error: TypeScript expects, from your own definition, for T to be accessed with K. Easy fix: [key as K].

The same thing applies to Array.reduce(), if you don't specify the type of the second argument, {}, TS will assume the accumulator is an empty object. By specifying {} as Partial<T> , you tell TS to expect the accumulator to have any number of the same keys as original, and not one more.

return /*...*/ as Pick<T, typeof keys[number]>

Finally, if you want the return result of pick to be correctly typed & have correct auto-completion, you need to specify the return type of Array.reduce() too, otherwise it will be of type Partial<T>, which, while useful, is not precise enough.

Luckily, the Pick utility type allows to create a new type from another, while specifying the keys which should stay. When you know the keys in advance, you can use union literal types, which would look like this: Pick<MyType, 'keyA' | 'keyB'>. In our case we don't know the values of those keys in advance, but typeof keys[number] returns a union of every literal type in the keys array, exactly what we need!

Coming back to our usePick composable, it's now really easy to implement:

export const usePick = <T extends Object, K extends keyof T>(   original: Ref<T>,   keys: K[] ) => {   return computed(() => {     return pick(original.value, keys)   }) }

We don't even need to provide a return type to usePick, the power of inference of TS is strong enough to know that it's the same result type our base pick function would have, but wrapped in a ComputedRef thanks to Vue's computed function.

You will get nice autocompletion when accessing the fields of picked.value, and warnings if you try to access not specified in the keys argument of usePick.

Here's the documentation for TypeScript's utility types, and the various ways you can create types from types.

Learn to add Focus Rings with Vue.js