loke.dev
Header image for Your Types Are Too Dry

Your Types Are Too Dry

Over-abstracting TypeScript interfaces with complex utility types often creates a maintenance nightmare that simple duplication would have avoided.

· 4 min read

You’ve been told that duplication is the root of all evil in programming. We’ve had "Don’t Repeat Yourself" (DRY) drilled into our heads since our first "Hello World." But when it comes to TypeScript, being a DRY extremist is exactly how you end up with a codebase that’s impossible to read and even harder to change.

The most senior move you can make in a complex TypeScript project is often to just copy and paste an interface.

The "Clever" Trap

We’ve all been there. You have a User type that comes from your database. Then you need a version for your API response that doesn’t have the passwordHash. Then you need a version for the "Update Profile" form where everything is optional.

The "clever" developer does this:

interface User {
  id: string;
  username: string;
  email: string;
  passwordHash: string;
  createdAt: Date;
}

// "Look how DRY I am!"
type UserResponse = Omit<User, 'passwordHash'>;
type UpdateUserRequest = Partial<Omit<User, 'id' | 'createdAt' | 'passwordHash'>>;

It looks elegant. It feels efficient. But you’ve just tightly coupled your API contract and your UI logic to your database schema.

Three months later, you decide to rename username to handle in the database. Suddenly, your API response breaks, and your frontend form starts throwing errors in parts of the app you haven't touched in weeks. You wanted a single source of truth, but you actually built a house of cards.

The Tooltip From Hell

The real cost of "Dry Types" is the developer experience. When I hover over a variable in VS Code, I want to see what's inside it.

If your types are too dry, the tooltip looks like this:
type ComplexUser = Pick<User, "id" | "email"> & Partial<Omit<BaseEntity, "deletedAt">> & { role: UserRole }

I don't want to do mental algebra just to figure out if email is optional. I want to see:

interface UserProfile {
  id: string;
  email: string;
  role: UserRole;
}

By explicitly defining the interface, you’re providing documentation. You’re telling the next developer (which is usually you in six months) exactly what this specific data structure requires without forcing them to trace a breadcrumb trail of Pick, Omit, and Exclude through five different files.

Decoupling is Worth the Duplication

Code duplication is a tool, not a failure. In TypeScript, types often represent boundaries.

The data your database needs is a different concern than the data your React component needs. Even if they happen to share the same five fields today, they are changing for different reasons.

- The database changes because of storage optimization or migrations.
- The UI changes because of UX requirements.

When you duplicate the interface, you give yourself the freedom to let those two things evolve independently. If you add a themePreference to the UI, you don't have to worry about whether the database-layer Pick logic is going to accidentally try to save that to a column that doesn't exist.

When to Actually Use Utility Types

I'm not saying we should delete Partial or ReturnType from the language. They have their place, usually in generic utilities or third-party library wrappers where you truly don't know the shape of the data beforehand.

If you’re building a FormWrapper<T> component, then yes, Partial<T> is your best friend. But for your domain models—the "Users," "Products," and "Orders" of your app—stay literal.

The "Wait and See" Rule

If you find yourself writing a utility type that is more than two levels deep—like Partial<Pick<Transform<T>>>—stop. Take a breath.

Try this instead:
1. Copy the original interface.
2. Paste it.
3. Rename it.
4. Modify the fields you actually need.

If, after six months, those two interfaces have stayed 100% identical and you’ve had to update both of them manually ten times, *then* you can think about abstracting them. But I’m willing to bet that they’ll have drifted apart by then, and you’ll be incredibly glad you didn't link them together with a complex web of type gymnastics.

Clean code isn't code that uses the fewest lines; it's code that is the easiest to reason about. Sometimes, that means being a little bit "wet."