Useful utilities

Alejandro Dustet

The health of a type system relies heavily upon how malleable it is, how clearly can it describe our domain’s description, and how good it can uphold our solution. Sometimes, we hear that a type system is too rigid, or it adds too many constraints as an argument against it. In our experience with the TypeScript type system, this is very far from the truth – mainly thanks to a great set of utilities.

Utilities you say

First things first, what are these utilities? The purpose of most of them could be type transformation. They are used to construct new type definitions or to compose and modify the existing ones for reusability. Since taxonomies are in our nature, we could group them into categories like:

These are in no form exclusive, and one can fulfill another’s role with some tinkering or Type Gymnastics ©.

The most versatile Record

A JSON object, or a collection of attributes, or even a hashmap, could be the broadest classification of any data we have in TypeScript. More often than not, we will need to handle some arbitrary set of properties. Typing those could be a nightmare. Sometimes we do, however, have some indication of the general shape of an object. What do we want to store (a value) and how might we retrieve it (a key). Having those under the type system can be a powerful ally. Consider this example to render the TabBarIcon using react-navigation

// Parameters that the tab bar elements will receive
// this type declaration would be required
// by react navigation
type TabBarParamList = {
  Home: undefined
  Settings: undefined
};
// We gather all possible items on the TabBar
type TabBarItem = keyof TabBarParamList
// Posible icon names
type IconName = "home-icon" | "settings-icon"
// Here we declare that the collection of tab bar
// items will be composed of Tab Bar Items as keys
// and an icon as the value, enforcing that all Tab Bar
// Items are assigned an icon
type TabBarIconMapping = Record<TabBarItem, IconName>

const iconMapping: TabBarIconMapping = {
  ["Home"]: "home-icon",
  ["Settings"]: "settings-icon",
};

interface TabBarIconProps {
  routeName: TabBarItem
}
// This would be the component we provide to
// react navigation to render
const TabBarIcon: FunctionComponent<TabBarIconProps> =
({
  routeName,
}) => {
  return <Icon name={iconMapping[routeName]} />
};

We now have a representation of the problem that is complete. As we continue to add items to our tab bar, the type system will take us through all the additions we need to make. When we add a new element to the TabBar we add it to the TabBarParamList and let TypeScript lead the way.

Putting the Util in utilities: Pick and Omit

A pair that shines when we need to stretch our type definitions is Pick and Omit. A personal favorite use-case is when we extract smaller presentational components from one with a moderate amount of properties. Let’s consider a chart displaying element. In general, data that it’s worth showing in this format tends to be complicated. It is normal to break apart sections of the chart to display portions of the data.

type Trend = "Upward" | "Downward"
type Snapshot = {
  id: number,
  income: number,
  trend: Trend,
  // Other attributes used on
  // Income component
  source: string,
}

type IncomeProps = Pick<Snapshot, "income" | "trend">
// or with Omit if it is shorter, I find Omit harder
// to read unless the type is defined right here
type IncomePropsOmit = Omit<Snapshot, "id" | "source">

export const Income: FunctionComponent<IncomeProps> = (
  { value, trend }
) => {
  // Rendering the component
}

We can always create another interface or type. But in this case, if the data changes significantly, we wouldn’t get alerted by the type system to fix our implementation.

One symptom to keep an eye out for using Pick and Omit is that we might end up with scattered pieces of type clips. Overusing them might indicate that the way data flows through our components is fighting the data model itself. So if you catch yourself using these a lot, it might be a sign that our approach could use a second look.

Going meta: Parameters and ReturnType

When we have a value and need to know its type, we can use typeof. What happens when we use it on a function? We always get back Function. When we need information about the return type or the parameters it needs, we can now reach for these utilities together with typeof. We can’t use a value with these type utilities; we have to use a type.

// method we don't have access to
import { saveCart } from "cartAPI"

// Arguments for the function
type CartCreationParams = Parameters<typeof saveCart>
// Return type for the function
type SavedCart = ReturnType<typeof saveCart>

// Now we can declare our own functions to operate
// with the external one in case some transformation
// is needed, the FormInput type is out of the scope
// of this example
const submit = (form: FormInput): SavedCart => {
  // The types will make sure we call the function
  // with the right arguments
  const cartParams: CartCreationParams = form.parse()
  return saveCart(cartParams)
}

When we need a set of functions external to our scope, reaching for these utilities will keep our implementation from becoming outdated.

Almost perfect but not quite Required, and Partial

Very useful when we need to express a different set of constraints for the same type. Particularly useful for factories(fishery) and methods to update parts of an object.

type LineItem = {
  id: number
  sku: string
  variant: string
  ownerId?: number
}

// If we have a special line item with an owner
// we can enforce it with our types
type CreateLineItemWithOwnerParams = Required<LineItem>
// If we need to update one, we will not need all
// the properties
type UpdateLineItemParams = Partial<LineItem>

The ability to have variants of the same type is what makes these two utilities very handy.

Takeaways

The strictness of using a type system does not have to come at the expense of losing flexibility. We can dance around the static definitions of the problems we are modeling, and by doing so, we’ll end up with an interconnected system. A system that will help us to stay up to date as it evolves. If having a rigorous set of types benefits one aspect of our logic but makes it hard to operate on others. We don’t need to re-declare our definitions; we can mold them to our needs, and by doing so, we enforce the relations in our system with the types.