The Importance of Deriving Types in TypeScript

Deriving types is important for a variety of reasons, including avoiding errors and representing types and relationships clearly in code. Let’s look at an example.

Deriving Types Instead of Declaring Them Anew

Imagine we have a system where information about a car needs to be retrieved from two separate subsystems. We have a function for retrieving data from each system, and then we assemble it into the complete representation we want to use in our application. This is very close to a real scenario in my current project.

type Car = {
  id: number;
  make: string;
  model: string;
  trim: string;
  year: number;
  milesDriven?: number;
  comments?: string;
};
function getMakeAndModelInfo(id: number) {
  return {
    make: 'Subaru',
    model: 'Impreza',
    trim: 'STi',
    year: 2010,
  };
}
function getUsedCarInfo(id: number) {
  // Fake setup where it might find it and might not
  if (Math.random() > 0.5) {
    return null;
  }
  return {
    milesDriven: 106000,
    comments: 'Driven hard but well maintained.',
  };
}

const carId = 2;
const car: Car = {
  id: carId,
  ...getMakeAndModelInfo(carId),
  ...getUsedCarInfo(carId),
};

This code works fine. It will pass tests. But when refactoring time comes, it won’t allow the type system to guide us as well as it should. For example, if we were to rename the comments field on the Car type to notes, we would get no errors in the getUsedCarInfo function or when using the spread operator to merge the objects into our completed Car object.

type Car = {
  id: number;
  make: string;
  model: string;
  trim: string;
  year: number;
  milesDriven?: number;
  // renamed this from comments to notes
  notes?: string;
};
...
function getUsedCarInfo(id: number) {
  // Fake setup where it might find it and might not
  if (Math.random() > 0.5) {
    return null;
  }
  return {
    milesDriven: 106000,
    // no type errors here
    comments: 'Driven hard but well maintained.',
  };
}

const carId = 2;
const car: Car = {
  id: carId,
  ...getMakeAndModelInfo(carId),
  // No type errors, and no notes value either. Somebody's going to be sad.
  ...getUsedCarInfo(carId),
};

Missing that during the refactor could lead to the notes silently disappearing from the UI, affecting outbound integrations our system feeds, and a host of other problems. I would like to believe that our integration tests would catch it, but I think we can do better. The benefits of a good type system that we can achieve in this scenario are:

  1. Fast feedback – we don’t have to wait for a test run to know we’ve done something wrong.
  2. Specific errors – it tells us exactly where the problem lies with file names and line numbers.
  3. Eliminating classes of bugs – for example, we don’t need to verify the lack of extraneous properties in a test to avoid typos in property names.

How do we do that? By deriving types instead of declaring them anew. Deriving related types gives the compiler better information about those type relationships so it can warn us about potential problems sooner. Here’s the above example updated using derived types.

type Car = {
  id: number;
  make: string;
  model: string;
  trim: string;
  year: number;
  milesDriven?: number;
  comments?: string;
};
function getMakeAndModelInfo(
  id: number,
): Pick<Car, 'make' | 'model' | 'trim' | 'year'> {
  return {
    make: 'Subaru',
    model: 'Impreza',
    trim: 'STi',
    year: 2010,
  };
}
function getUsedCarInfo(
  id: number,
): null | Pick<Car, 'milesDriven' | 'comments'> {
  // Fake setup where it might find it and might not
  if (Math.random() > 0.5) {
    return null;
  }
  return {
    milesDriven: 106000,
    comments: 'Driven hard but well maintained.',
  };
}

const carId = 2;
const car: Car = {
  id: carId,
  ...getMakeAndModelInfo(carId),
  ...getUsedCarInfo(carId),
};

With this code, if we rename the comments field on the Car type to notes again, we will immediately get a type error that indicates we can’t tell Pick to include the comments field. That’s exactly the kind of help we want. Sure, it takes a bit longer because we have to declare explicit return types on our functions, but it’s worth the typing.

Other Reasons to Use Derived Types

Our example gets at one important reason to use derived types, but there are others.

Improved Consistency for Nullable and Optional Fields

If you’ve ever run into the incompatibility between types like `string | null` and `string | undefined`, you know what a disappointment that can be. By deriving types, we dramatically increase the probability that field types will line up as expected.

A Better Expression of the Relationships Between Types in Your Codebase

This is really the core of what we’re trying to do in TypeScript: build a representation of the entities and functions and their relationships in our codebase. Deriving types makes those relationships explicit.

Prioritizing Good Use of Types in the Development Process

We’ve been using the term Type-First Development to get across the importance of types and when the work of building types should happen. They are the tip of the spear, the first form of executable guidance in our Test-Driven-Development practice, and the foundation that we build on. Build them first.

Getting Better at Thinking in Terms of TypeScript’s Type System

The example above is simple, but working with types can be challenging as you work to solve more complex problems. Those challenges are great learning opportunities. The more you choose to tackle them, the better you’ll get at expressing your thoughts through the type system.

Tools for building derived types

Top Down

Building derived types top-down means starting with a type and creating derived types that represent only a portion of the first type. We tend to build types top-down when starting with an ideal representation that we want to have in the core of our code — our business domain and logic. We then break those types into partial representations when we need to refer to only subsets of properties for various purposes, like user input, integrations, or serialization.

The TypeScript documentation’s Utility Types page has pretty much everything you need to build top-down derived types. Here are a few snippets about the specific tools we tend to use most frequently.

Pick / Omit

Create a type based on an existing type that includes or excludes a set of properties by name. For example:

type Car = {
  id: number;
  make: string;
  model: string;
  trim: string;
  year: number;
  milesDriven?: number;
  comments?: string;
};
type UsedCarInfo = Pick<Car, 'id' | 'milesDriven' | 'comments'>;
type MakeAndModelInfo = Omit<Car, 'milesDriven' | 'comments'>;

Partial

Partial creates a type where every property on the original type is optional. This is especially useful when we don’t know ahead of time which values will be present. Examples include collecting user input in optional fields or dealing with messages about only fields that changed on a type.

type optional = Partial<Car>;
// {
//   id?: number;
//   make?: string;
//...

Extract / Exclude

Extract can be used to get the union of two types, and Exclude gives the properties excluded by the union. For example:

type shared = Extract<keyof UsedCarInfo, keyof MakeAndModelInfo>;
// shared = 'id'
type notShared = Exclude<keyof UsedCarInfo, keyof MakeAndModelInfo>;
// notShared = 'make' | 'model' | 'trim' | 'year' | 'milesDriven' | 'comments'

Bottom Up

Creating types bottom-up builds larger or wrapped types based off of smaller, more basic types. They can be built using common TypeScript tools like the following.

& (Type Intersection)

The type intersection operator can combine two types to create one type with all the properties of both types.

type Color = { r: number, g: number, b: number };
type Shape = { shape: 'square' | 'triangle' | 'circle' };
type ColoredShape = Color & Shape;

Interfaces

Interfaces in TypeScript support multiple inheritance. There are some nuanced differences between this approach and type intersection when it comes to conflicts in property names, but outside that case, they’re very similar.

interface Color {
  r: number;
  g: number;
  b: number;
}
interface Shape {
  shape: 'square' | 'triangle' | 'circle';
}
interface ColoredShape extends Color, Shape {};

Mapped Types

Mapped types are another way to derive a type with a lot of control over the properties of the resulting type. This is an extensive topic and I won’t try to cover it all. But, here is one simple example of using a mapped type to maintain the link between types and get the fast feedback and refactoring support we want (example taken from the TypeScript documentation for mapped types).

type OptionsFlags = {
  [Property in keyof Type]: boolean;
};
type FeatureFlags = {
  darkMode: () => void;
  newUserProfile: () => void;
};
type FeatureOptions = OptionsFlags;

Derive more types; Have more fun

Deriving types provides more value by representing types and relationships clearly in code. Additionally, it’s a lot more fun to solve these problems than to defer the effort and wind up with hidden relationships that frustrate your refactoring and tracing efforts. See if you can take these concepts further during your next TypeScript coding session.

 
Conversation

Join the conversation

Your email address will not be published. Required fields are marked *