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:
- Add or remove constraints:
Partial
,Readonly
, orRequired
. - Filter certain properties:
NonNullable
. - Compose sub-types from existing ones:
Pick
orOmit
. - Infer types:
Parameters
orReturnType
.
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.