Deriving vs Decoupling: When NOT to be a Typescript Wizard

devvie

devvie

@devvie

Deriving vs Decoupling: When NOT to be a Typescript Wizard

One of the most common pieces of advice for writing maintainable code is to "Keep code DRY", or more explicitly, "Don't Repeat Yourself".

One way to do this in JavaScript is to take repeating code and capture it in functions or variables. These variables and functions can be reused, composed and combined in different ways to create new functionality.

In TypeScript, we can apply this same principle to types.

What Is A Derived Type?

A derived type is a type which relies on, or inherits from, a structure of another type. We can create derived types using some of the tools we've used so far.

We could use interface extends to make one interface inherit from another:

interface Album {
  title: string;
  artist: string;
  releaseYear: number;
}
 
interface AlbumDetails extends Album {
  genre: string;
}

AlbumDetails inherits all of the properties of Album. This means that any changes to Album will trickle down to AlbumDetails. AlbumDetails is derived from Album.

Another example is a union type.

type Triangle = {
  type: "triangle";
  sideLength: number;
};
 
type Rectangle = {
  type: "rectangle";
  width: number;
  height: number;
};
 
type Shape = Triangle | Rectangle;

A derived type represents a relationship. That relationship is one-way. Shape can't go back and modify Triangle or Rectangle. But any changes to Triangle and Rectangle will ripple through to Shape.

Deriving vs Decoupling

When you derive a type from a source, you're coupling the derived type to that source. If you derive a type from another derived type, this can create long chains of coupling throughout your app that can be hard to manage.

When Decoupling Makes Sense

Let's imagine we have a User type in a db.ts file:

export type User = {
  id: string;
  name: string;
  imageUrl: string;
  email: string;
};

We'll say for this example that we're using a component-based framework like React, Vue or Svelte. We have a AvatarImage component that renders an image of the user. We could pass in the User type directly:

import { User } from "./db";
export const AvatarImage = (props: { user: User }) => {
  return <img src={props.user.imageUrl} alt={props.user.name} />;
};

But as it turns out, we're only using the imageUrl and name properties from the User type. It's a good idea to make your functions and components only require the data they need to run. This helps prevent you from passing around unnecessary data.

Let's try deriving. We'll create a new type called AvatarImageProps that only includes the properties we need:

import { User } from "./db";
type AvatarImageProps = Pick<User, "imageUrl" | "name">;

But let's think for a moment. We've now coupled the AvatarImageProps type to the User type. AvatarImageProps now not only depends on the shape of User, but its existence in the db.ts file. This means if we ever move the location of the User type, or split it into separate interfaces, we'll need to think about AvatarImageProps.

Let's try the other way around. Instead of deriving AvatarImageProps from User, we'll decouple them. We'll create a new type which just has the properties we need:

type AvatarImageProps = {
  imageUrl: string;
  name: string;
};

Now, AvatarImageProps is decoupled from User. We can move User around, split it into separate interfaces, or even delete it, and AvatarImageProps will be unaffected.

In this particular case, decoupling feels like the right choice. This is because User and AvatarImage are separate concerns. User is a data type, while AvatarImage is a UI component. They have different responsibilities and different reasons to change. By decoupling them, AvatarImage becomes more portable and easier to maintain.

What can make decoupling a difficult decision is that deriving can make you feel 'clever'. Pick tempts us because it uses a more advanced feature of TypeScript, which makes us feel good for applying the knowledge we've gained. But often, it's smarter to do the simple thing, and keep your types decoupled.

When Deriving Makes Sense

Deriving makes most sense when the code you're coupling shares a common concern. The examples in this chapter are good examples of this. Our as const object, for instance:

const albumTypes = {
  CD: "cd",
  VINYL: "vinyl",
  DIGITAL: "digital",
} as const;
 
type AlbumType = (typeof albumTypes)[keyof typeof albumTypes];

Here, AlbumType is derived from albumTypes. If we were to decouple it, we'd have to maintain two closely related sources of truth:

type AlbumType = "cd" | "vinyl" | "digital";

Because both AlbumType and albumTypes are closely related, deriving AlbumType from albumTypes makes sense.

Another example is when one type is directly related to another. For instance, our User type might have a UserWithoutId type derived from it:

type User = {
  id: string;
  name: string;
  imageUrl: string;
  email: string;
};
 
type UserWithoutId = Omit<User, "id">;
 
const updateUser = (id: string, user: UserWithoutId) => {
  // ...
};

Again, these concerns are closely related. Decoupling them would make our code harder to maintain and introduce more busywork into our codebase.

The decision to derive or decouple is all about reducing your future workload.

Are the two types so related that updates to one will need to ripple to the other? Derive.

Are they so unrelated that coupling them could result in more work down the line? Decouple.