Thibault Maekelbergh

Typescript utilities I always include

If you've used Partial<...>, Required<...> or Awaited<...> you've encountered Utility Types. They serve as easy to remember conveniences around more complex types that you might forget the exact syntax for, or that are repeated throughout your codebase.

Here is a collection of types I tend to include at the start of most of my projects or find myself adding them at a later stage.

Void

Signature: declare type Void = () => void;

TS includes the void keyword to annotate the return value of a function. I often find it easier to have a type Void that takes no parameters, just so that I can assign it to a function and at a glance know that this function is a void function that's just called.

Imagine having a React props interface taking several handler functions:

typescript
interface Props {
onChangeText: () => string;
onChange: () => GestureResponderEvent;
onPositionChange: () => Coordinates<number>;
onDismiss: () => void;
onError: () => Error | InputError;
}

You now have to scan over a bunch of arrow functions to see at a glance what the return type is and where the one returning void is located.

With the Void utility this becomes clearer in the list that we have an opaque void function in there:

typescript
interface Props {
onChangeText: () => string;
onChange: () => GestureResponderEvent;
onPositionChange: () => Coordinates<number>;
onDismiss: Void;
onError: () => Error | InputError;
}

ObjectKeys<O>, ObjectValues<O>

Signature: declare type ObjectKeys<O> = Array<keyof O>;, declare type ObjectValues<O> = O[keyof O];

These are mainly to mimic the behaviour we have in runtime JS with Object.keys({ … }) and Object.values({ … }). Before working with TS we used Flow at ITP and that actually had these built-in. It's the one remnant from Flow that today I still like to use, just because it resembles the JS implementation so much.

It's really useful for being restrictive in what properties can be passed to a component for example:

typescript
// colors.ts
const Colors = {
colorNeutral999: 'rgba(0,0,0,1)',
colorNeutral000: 'rgba(255,255,255,1)',
colorSuccess500: 'rgba(128,194,66,1)',
colorError500: 'rgba(255,92,92,1)',
}
// Button.tsx
import { type Colors } from './colors';
interface Props {
backgroundColor: ObjectValues<typeof Colors>;
}
const Button = ({ backgroundColor }: Props) => <Pressable style={{ backgroundColor }}/>;

This would only allow to pass colors defined in our style system and not wrong colors which might deviate in tone or hue.

EmptyObject

Signature: declare type EmptyObject = Record<string, unknown>;

This one is mainly to work around ESLint complaining about the object type which actually doesn't result in object literals but also in other types. The recommendation here is to use a Record<K, V> (a built-in TS Utility Type) and EmptyObject just serves as an alias to that so that in code we make the following intent clear to other people in the team:

This data is of type EmptyObject, so I actually don't care what ends up in the object or how its structure looks like I just need it to be an object literal.

Argument<F, I>

Signature: declare type Argument<F, I> = Parameters<F>[I];

I use this a lot when writing interface on other pieces of code. Sometimes you are writing interfaces to enhance functionality of third parties. Take this piece of code which adds an initialisation check to start a transaction via Sentry, configure some scope and then return the transaction:

typescript
transaction(context: Argument<typeof Sentry.startTransaction, 0>) {
if (!this.initialized) {
logger.warn(`Could not start transaction '${context.name}'. Tracing service not initialized`);
return { finish: () => {} };
}
const transaction = Sentry.startTransaction(context);
Sentry.getCurrentHub().configureScope(scope => scope.setSpan(transaction));
return transaction;
}

Notice how the Argument<typeof Sentry.startTransaction, 0> is annotated to context. Without this you would probably be annotating context with your own type based on what you see Sentry returns. Not only can you introduce mismatches here, it's also possible to limit it to only a subset of types when Sentry would add an extra property later to this interface. That would mean you would need:

- to know about that change in the first place

- update your own type definition manually.

When using the Argument<F, I> generic here we are just using inference provided by TS: reading from the function's type definition, grabbing the first argument and copying its exact structure.

This is mainly useful when your third party code or dependencies don't export interfaces or types and you still prefer inference over manual declaration. And especially useful in situations where you are writing enhancers / higher order functions / proxies / services or interface layers.

Another good example of this pattern would be React.ComponentProps<typeof View> which would copy the props signature.

ISODate

Signature: declare type ISODate = ReturnType<Date['toISOString']>;

Again more of an alias type but one that makes intent perfectly clear. This will technically still resolve to string (the return type of new Date.toISOString()) but with different APIs returning different time formats when we annotate with ISODate we say: this date is definitely in a valid ISO format:

typescript
interface CocktailRecipe {
createdAt: ISODate;
ingredients: Alcohol[];
rating?: 1 | 2 | 3 | 4 | 5;
}
async function getRecipes() {
const res = await fetch('...');
const data = await res.json();
return data as CocktailRecipe;
}

ArrayItem<A>

Signature: declare type ArrayItem<A> = A[number];

A little bit like the Arguments<F, I> described above but mainly with the goal to unwrap an array. Using this you can grab any item from an array if you are just interested in the items in there rather than the collection:

typescript
// imagine a 3rd party library shipping this type with inline array definition
type TimelineErrors = Array<{ name: string, day: DaysOfWeekEnum }>;
// and our code is handling those errors and enhancing them if they occured in the weekend
function withImportance(err: ArrayItem<TimelineErrors>) {
if (['saturday', 'sunday'].includes(err.day)) {
return { ...err, important: false };
}
return err;
}
function enhanceErrors(errors: TimelineErrors) {
return errors.map(withImportance);
}

Without ArrayItem<A> we would need to either manually declare TimelineErrors[number] or need to redeclare a type from a third party as { name: string, day: DaysOfWeekEnum }

Making utilities globally available

You might have noticed that in the signature part for these utilities I always wrote declare type ...

This is because I use these as globally declared types in order to avoid having to import them. Typically these would go into a decls/global.d.ts or src/global.d.ts file. That way we can make them globally available to the TS engine so we can use them in our code without imports.

The full file is below. I encourage you to start adopting your own and default provided TS Utility Types in your codebases, they have certainly made my life a lot easier.

typescript
/** Convenience around a void function, much like typed languages */
declare type Void = () => void;
/** Returns the keys from an object as a union */
declare type ObjectKeys<O> = keyof O;
/** Returns the values from an object as a union */
declare type ObjectValues<O> = O[keyof O];
/**
* An empty dictionary/record with unknown property values.
* Use this when you just need an opaque object type instead of `object`
*/
declare type EmptyObject = Record<string, unknown>;
/** Get a specific argument from parameters */
declare type Argument<F, I> = Parameters<F>[I];
/** Date string in ISO format, typically returned by Date.toISOString() */
declare type ISODate = ReturnType<Date['toISOString']>;
/** Grab any item from an array */
declare type ArrayItem<A> = A[number];