Announcing: A Free Book, A New Course, A Huge Price Cut...
It's a massive ship day. We're launching a free TypeScript book, new course, giveaway, price cut, and sale.
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.
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
.
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.
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.
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.
This is a preview from my upcoming book.
If you’d like to receive updates about the book and all things TypeScript, subscribe below:
Share this Book Teaser with your friends
It's a massive ship day. We're launching a free TypeScript book, new course, giveaway, price cut, and sale.
Learn why the order you specify object properties in TypeScript matters and how it can affect type inference in your functions.
Learn how to use corepack
to configure package managers in Node.js projects, ensuring you always use the correct one.
Learn how to strongly type process.env in TypeScript by either augmenting global type or validating it at runtime with t3-env.
Discover when it's appropriate to use TypeScript's any
type despite its risks. Learn about legitimate cases where any
is necessary.
Learn why TypeScript's types don't exist at runtime. Discover how TypeScript compiles down to JavaScript and how it differs from other strongly-typed languages.