TypeScript Type Extension
You're working with a third-party API client, and its User type is almost right for your app, but you need to tack on a permissions field. You could copy the whole type into your codebase, but then you're stuck maintaining a duplicate every time the library updates. Or you could extend it.
Extending types is one of those skills that keeps your codebase from turning into a tangled mess of copy-pasted definitions. In this guide, we'll cover every practical way to extend types in TypeScript, when to choose each approach, and the mistakes that'll trip you up along the way.
Adding Properties to a TypeScript Type
To add properties to an existing type, reach for interfaces and the extends keyword. If you have a User type and need a version that also carries a country field, define a new interface:
interface User {
name: string;
email: string;
}
interface ExtendedUser extends User {
country: string;
}
// ExtendedUser has: name, email, country
const admin: ExtendedUser = {
name: "Alice",
email: "alice@example.com",
country: "Canada",
};
The ExtendedUser interface picks up all the properties from User without touching the original definition. When working with TypeScript interface, this pattern lets you build hierarchies that are easy to follow and safe to refactor.
In Convex applications, this comes up often when you need to add application-specific fields on top of your database schema types.
Using Intersection Types to Combine Types
When you're working with type aliases instead of interfaces, the extends keyword isn't available. You use the & operator to combine types. This works equally well for merging two separate types or for extending an existing type with new fields inline:
type Person = {
name: string;
age: number;
};
type Address = {
street: string;
city: string;
};
// Combine two existing types
type Customer = Person & Address;
// Or extend inline — no need for a separate type declaration
type PremiumCustomer = Person & {
id: string;
email: string;
tier: "gold" | "platinum";
};
The result has all properties from every type in the intersection. Utility types like Omit<T, K> and Pick<T, K> work well alongside intersection types when you need more granular control over what gets included.
When building data models with Convex, intersection types help create comprehensive types that reflect the full shape of your domain objects.
Extending Interfaces with New Fields
You can extend an interface with as many new fields as you need, including nested objects:
interface Widget {
id: number;
name: string;
}
interface ExtendedWidget extends Widget {
metadata: {
createdAt: Date;
updatedAt: Date;
createdBy: string;
};
}
This creates an ExtendedWidget with all Widget properties plus the metadata field. Use the extends keyword any time you want to build on an existing interface in a way that's explicit and readable.
In Convex applications, this keeps your base schema definitions clean while still letting you attach application-specific metadata where you need it.
Overriding Property Types When Extending
What if you want to extend a type but also change the type of one of its properties? A direct override in an interface will throw an error if the types are incompatible. The solution is to use Omit to drop the property first, then redefine it:
interface ProductRecord {
id: string | number; // SDK uses either, but your app always uses strings
name: string;
status: unknown;
}
// Override `id` to be strictly a string
type StrictProductRecord = Omit<ProductRecord, "id"> & {
id: string;
};
// Also works with interfaces:
interface StrictProductRecordInterface extends Omit<ProductRecord, "id"> {
id: string;
}
This pattern is the right tool when you're working with third-party types that use looser types than your app needs. The Omit drops the original property, and your intersection or extension redefines it at the narrower type.
See TypeScript Omit for more on selectively removing properties before combining types.
Union Types for Flexible Shapes
Union types let a variable be one of several shapes. This is different from extending or merging (which combines all properties into one required set). Use union types when an entity can be one thing or another:
type Admin = {
id: number;
name: string;
role: 'admin';
};
type Customer = {
id: number;
name: string;
role: 'customer';
};
type User = Admin | Customer;
A User can be an Admin or a Customer, but not a mix of both. TypeScript uses the role discriminant to narrow the type in a conditional. This pattern, known as a discriminated union, is the right choice when different subtypes need different handling.
In Convex, you can use the v.union() validator to validate data against union types.
Safely Extending Third-Party Types
When a library doesn't expose all the properties your app needs, extend the type in your own code rather than modifying anything in node_modules:
// From a third-party library
interface ThirdPartyWidget {
id: number;
name: string;
}
// Your extended type
interface AppWidget extends ThirdPartyWidget {
metadata: {
createdAt: Date;
updatedAt: Date;
};
}
For cases where you need TypeScript itself to recognize extra properties globally, use declaration merging in a .d.ts file. A common example is extending the browser's Window object with a custom analytics instance your app attaches at startup:
// types/globals.d.ts
declare global {
interface Window {
analytics?: {
track(event: string, properties?: Record<string, unknown>): void;
identify(userId: string): void;
};
}
}
export {};
Now window.analytics is typed everywhere in your project without patching any library. This technique is called module augmentation, and it's how you add properties to third-party types you don't own.
If you need the extension itself to be reusable across types, generics let you parameterize what gets added.
For Convex projects, extending library types this way lets you layer app-specific metadata onto query results while keeping full type safety in your functions.
Interfaces vs. Type Aliases for Extension
Both interfaces and type aliases can extend other types, but the mechanics differ. The interface vs type article covers this in depth, but here's the quick version for extension specifically:
- Use
interface extendswhen building hierarchies you might reuse with classes or declaration merging - Use type alias
&when composing types you don't control, or when you need to mix in unions and conditional types - Either approach works for simple field additions. Pick the one that matches what's already around it
When working with Convex, type alias intersections are useful for creating derived types that represent relationships between entities, since they compose cleanly with Convex's validator types.
Extending Types Without Modifying Originals
Mapped types let you transform every property of an existing type into a new shape without touching the original. Here's how to build a ReadonlyUser from a User:
type User = {
name: string;
email: string;
};
type ReadonlyUser = {
readonly [P in keyof User]: User[P];
};
In practice, you'd use TypeScript's built-in Readonly<T> instead, but understanding the mapped type underneath helps when you need to build your own transformations. Similarly, Partial<T> makes all properties optional, which is handy for update payloads and patch operations.
In Convex apps, this prevents accidental mutations to shared data and makes component boundaries more predictable.
Where Things Go Wrong
A few pitfalls that come up regularly when extending types:
Conflicting properties produce never. If you intersect two types that have the same property with incompatible types, TypeScript resolves it to never, which means nothing can satisfy that property:
type A = { id: string };
type B = { id: number };
type C = A & B;
// C.id is `string & number`, which resolves to `never`
// No value can satisfy this — TypeScript will error when you try to use it
Use Omit to drop the conflicting property from one side before intersecting.
Interfaces can't extend non-object types. You'll hit this error if you try to extend a union type directly:
type StringOrNumber = string | number;
// Error: An interface can only extend an object type
interface Extended extends StringOrNumber {
label: string;
}
// Fix: use a type alias with an intersection instead
type Extended = StringOrNumber & { label: string };
Declaration merging is interface-only. If you accidentally declare the same type alias twice you get a "Duplicate identifier" error, while the same interface name merges silently. This matters when augmenting third-party library types. You need an interface for it to work. See the interfaces article for a full breakdown of declaration merging behavior.
Choosing the Right Approach
Here's a quick reference for picking the right extension strategy:
| Situation | Approach |
|---|---|
| Adding fields to an interface | extends keyword |
| Combining two type aliases | & intersection |
| Overriding a property type | Omit<T, K> & { ... } |
| Augmenting a library type globally | Declaration merging in .d.ts |
| Transforming all properties | Mapped types / Readonly<T>, Partial<T> |
| Representing one-of shapes | Union types | |
Final Thoughts on Extending TypeScript Types
TypeScript gives you several ways to extend types, and each one fits different situations. Interfaces with extends work well for hierarchies that grow over time. Intersection types give you more options when working with type aliases. Mapped types let you derive new shapes without duplicating definitions.
The biggest thing to remember: don't copy-paste type definitions. If you find yourself writing the same shape in two places, there's almost always an extension pattern that keeps them in sync automatically.