40.7 Keeping Types DRY: When to Extract and When to Inline
Look, we need to talk about your types. I’ve seen the code. The sprawling, 20-property interfaces defined in-line for a single function argument. The string literals copy-pasted across seven different files. It’s a maintenance nightmare waiting to happen, and I’m here to be your nightmare-intervention friend.
The principle of DRY—Don’t Repeat Yourself—isn’t just for your logic. It’s absolutely critical for your type definitions. A change in one part of your data model should ripple through your entire codebase correctly, courtesy of the TypeScript compiler, not force you on a bloody safari to hunt down every duplicate definition. The compiler is your friend, your ally, your incredibly pedantic robotic code-reviewer. Use it.
The Unholy Trinity: type, interface, and Inline Anons
First, let’s be clear about our tools. You have three main ways to define an object shape:
// 1. The type alias
type UserProps = {
id: number;
name: string;
email: string;
isAdmin: boolean;
};
// 2. The interface
interface UserProps {
id: number;
name: string;
email: string;
isAdmin: boolean;
}
// 3. Inline (an anonymous type)
function createUser(user: { id: number; name: string; email: string; isAdmin: boolean }) {
// ...
}
The inline definition in example 3 is the villain of our story today. It’s the junk drawer of type definitions. Sure, it’s fine for a one-off, throwaway object, but the moment you see that same shape appear in a second function parameter or a return type, you’ve sinned. You’ve created a duplicate. Now, if the business decides “isAdmin” should be “adminStatus” with an enum, you get to change it in multiple places. Hope you like find-and-replace!
The Golden Rule of Extraction
Here’s the rule: Extract a type when it’s used in more than one place. That’s it. That’s the whole thing. The “place” can be another type definition, a function parameter, a variable, the return value of multiple functions—you get the idea.
Let’s fix our sinful inline example:
// Extract it once. Here, I'm using `interface` because I might want to `extends` it later.
interface UserProps {
id: number;
name: string;
email: string;
isAdmin: boolean;
}
// Now use it everywhere
function createUser(user: UserProps) { ... }
function updateUser(id: number, updates: Partial<UserProps>) { ... }
function getUser(): Promise<UserProps> { ... }
Boom. Now when the product manager slides into your DMs and says, “Hey, can we add an optional avatarUrl field to users?”, you change one line of code.
interface UserProps {
id: number;
name: string;
email: string;
isAdmin: boolean;
avatarUrl?: string; // One change here...
}
…and every function that uses UserProps will automatically and correctly reflect that the field is now available. The compiler will tell you exactly where the new optional field might cause issues. This isn’t just convenient; it’s professional-grade error prevention.
When Inlining is Actually Acceptable
There are, of course, exceptions. Inlining is perfectly acceptable when the type is truly, genuinely specific to a single location and will never be reused. This often happens with function props for React components or complex event handlers.
// This is probably fine to keep inline. It's the definition of a one-off.
function sendEmail(
to: string,
subject: string,
body: string,
options: { // This object shape is specific to this function's configuration.
priority: 'high' | 'low';
retryOnFail: boolean;
}
) {
// ...
}
Even here, though, if options grows beyond two or three properties, or if you find yourself writing a similar options object elsewhere, for the love of good code, extract it.
Going Beyond Simple Extraction: Composition
This is where you level up. DRY isn’t just about extracting whole types; it’s about composing them. Let’s say you have a BaseUser for internal processing and an ApiUser for what you send over the wire. One is a subset of the other. This is a classic job for composition.
interface BaseUser {
id: number;
name: string;
email: string;
hashedPassword: string; // Sensitive, never send this!
createdAt: Date;
}
// Instead of copying all the common fields, create the API type by
// reusing BaseUser and then omitting the dangerous field.
type ApiUser = Omit<BaseUser, 'hashedPassword'> & {
// And maybe add some API-specific fields
lastLogin: string; // ISO date string for serialization
};
// Now, a function to convert from internal to external representation
function getUserForApi(user: BaseUser): ApiUser {
const { hashedPassword, createdAt, ...safeUser } = user;
return {
...safeUser,
lastLogin: createdAt.toISOString(), // Example transformation
};
}
See what happened? BaseUser is the single source of truth for the core user properties. ApiUser is built on top of it. If we add a phoneNumber field to BaseUser, it automatically flows into ApiUser (as a string, which is fine for the API) and the compiler will help us handle it in the transformation function. This is the power of DRY types. It makes your system resilient to change.
The pitfall to avoid here is reaching for composition too early. If you only have one type, just define the one type. Don’t create a labyrinth of Omit, Pick, and Extends for a type that’s used in one function. Apply the golden rule: extract and compose when you see duplication, not before.