`type` Broke Library Augmentation - Use Interfaces
A production bug: type aliases block library augmentation, causing TypeScript errors and any casts.
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
- TypeScript's
typeandinterfaceboth define object shapes but serve different purposes. interfaceshines for domain entities — supportsextends,implements, and declaration merging.typeis your tool for unions, tuples, function signatures, and mapped types.- Performance difference is negligible at compilation; choosing wrong leads to maintenance debt.
- Biggest production mistake: using
typewhen you need declaration merging for library augmentation.
Think of a type or interface like a job description at a company. Before you hire someone, you write down exactly what skills and responsibilities that role requires — 'must know JavaScript, must handle billing, must have an email address.' TypeScript types and interfaces do the same thing for your data. They say: 'any object that wants to be a User must have a name, an email, and an age.' The difference between them is a bit like the difference between a sticky note (type) and an official HR form (interface) — both describe the role, but the HR form can be updated and extended department by department, while the sticky note is a fixed snapshot.
You've seen both type and interface in TypeScript codebases. They look interchangeable — both can describe an object's shape. But they aren't. Picking the wrong one causes real pain: you'll hit compile errors when trying to implements a type, or you'll be stuck when a third-party library expects to merge declarations and yours are sealed. Understanding the difference isn't academic trivia — it's the difference between ten-line workarounds and clean, maintainable types. This article lays out the concrete rules, the edge cases that bite you in production, and a decision framework you can use today.
Why `type` Breaks Library Augmentation — Use `interface`
In TypeScript, both type and interface define shapes, but they differ in a critical way: interface supports declaration merging — the ability to add new members to an existing interface across multiple declarations. type aliases are closed; once defined, they cannot be extended or augmented. This means if you publish a library that exports a type for its configuration or state shape, consumers cannot extend it to add custom fields. With an interface, they can — seamlessly, without modifying your source. This matters in practice because real-world systems rely on augmentation: think of Express's Request object, where middleware adds properties like user or session. Express uses interface (via declare global), so any middleware can augment it. If it used type, each middleware would need a new type alias, breaking composability. The rule: use interface for public API shapes that others may extend; use type for unions, tuples, or internal derived types.
type for a configuration object, consumers cannot augment it. They must fork or cast — both defeat type safety.type Config = {...}. Consumers tried to add custom fields via type ExtendedConfig = Config & {...} but lost autocomplete and broke downstream types. The fix: change to interface Config and re-export. Rule: if a shape is meant to be extended (config, context, state), always use interface.interface supports declaration merging; type does not.interface for public API shapes that others may extend.type for unions, tuples, or internal derived types only.Type Aliases — Snapshots of Any Shape You Can Imagine
A type alias does exactly what its name says: it gives a name to any type expression. That's broader than it sounds. You can alias a primitive, a union, a tuple, a function signature, or a complex object — anything TypeScript can express, you can name with type.
This is where type shines over interface: flexibility. An interface can only describe an object shape. A type can describe 'a string OR a number', a 'function that takes two numbers and returns a boolean', or even 'a tuple where position 0 is always a string and position 1 is always a Date.' You simply can't express those ideas with interface alone.
In a real codebase you'll use type aliases constantly for union types — things like an API response that can be either a success payload or an error object. You'll also use them to document function signatures so every developer on your team knows exactly what a callback or handler is supposed to look like before they write a single line.
type for unions prevents entire classes of bugs where invalid states slip through.type PaymentState = 'pending' | 'completed' | 'failed' makes it impossible to accidentally assign an invalid state.type when you need to describe what interface cannot — unions, tuples, primitives, function signatures.type is the only option for non-object shapes.Interfaces — Contracts That Can Grow and Merge
An interface describes the shape of an object and nothing else — but it does that job exceptionally well. Its real superpower is something called declaration merging: if you declare the same interface name twice, TypeScript merges them into one. This sounds niche until you realise it's the mechanism behind every third-party library that lets you extend their types from your own code, without touching the library source.
Interfaces also support extends, which works just like class inheritance. You can build a hierarchy of contracts — a BaseEntity with id and createdAt, extended by User which adds email, extended further by AdminUser which adds permissions. This makes your type system document your domain model in a way that mirrors how you'd describe it to a new teammate.
For almost any object shape that represents a domain entity — User, Product, Order, Invoice — interface is the idiomatic TypeScript choice. When you're describing something your app will create many instances of, or something that other parts of the codebase will extend or implement, interface communicates that intent clearly.
interface with a property that already exists but with a different type, you get an error. Worse, if you augment a third-party interface and make the property required, existing code that doesn't provide it will break. Always add new properties as optional (?).interface for objects others might extend — domain entities, component props, and class contracts.Intersection Types and Generics — Composing Complex Shapes
Once you understand types and interfaces individually, the real power comes from combining them. Intersection types (using &) let you merge multiple types into one that must satisfy all of them simultaneously. Think of it as 'this thing must be a User AND have these extra properties.' It's composition instead of inheritance.
Generics add a dimension of reusability that transforms your types from single-use descriptions into flexible templates. A generic type is like a function — it takes a type as a parameter and returns a new type. You've already seen this with ApiResponse<T> above. This is how TypeScript's own built-in utilities like Partial<T>, Required<T>, Pick<T, K> and Readonly<T> work under the hood.
In real-world code, you'll use these patterns every time you write shared utility functions, data fetching hooks, form handlers, or anything that needs to work with multiple different entity types. Getting comfortable with generics is the single biggest jump from 'TypeScript beginner' to 'TypeScript intermediate.'
PaginatedProduct, PaginatedUser, etc. When you later add a PaginatedOrder, you'd copy-paste. A single PaginatedResult<T> eliminates that. The performance impact is compile-time only — no runtime overhead.Partial<Pick<T, K>> is a production-grade pattern for selective updates.Type Guards — Making TypeScript Trust Your Runtime Logic
Here's a scenario that catches almost every developer moving from JavaScript to TypeScript: you have a variable typed as string | number — a union type. You want to call .toUpperCase() on it. TypeScript refuses, because what if it's a number? You need to prove to the compiler that at this specific point in the code, it's definitely a string.
Type guards are the mechanism for that proof. The simplest form is a typeof or instanceof check inside an if block — TypeScript understands these natively and narrows the type automatically inside that block. But when you're working with custom object types (not primitives), you need a user-defined type guard: a function that returns 'value is SomeType' in its signature.
This pattern is everywhere in production code — any time you receive data from an external source (an API, a user event, localStorage), you can't know the shape at compile time. Type guards are how you bridge the gap between 'unknown blob of JSON' and 'fully typed domain object.'
as assertions (lying to the compiler) or wrap everything in unsafe any. Both create runtime holes. A user-defined type guard costs a few microseconds per check — negligible compared to the safety gain.as assertions.When to Choose type vs interface: A Production Decision Guide
By now you know the capabilities of each. The real question is: which one do you actually write? The answer depends on the role that type plays in your codebase.
Start with interface for object shapes that represent domain entities — User, Product, Order. These are the backbone of your business logic. They benefit from extends, implements, and the ability for other modules to augment them. Interface communicates 'this is a contract'.
Use type for everything else: unions (Status = 'idle' | 'loading'), tuples ([lat, lng]), function signatures ((id: string) => Promise<User>), mapped types, utility types (Nullable<T>), and any composition involving intersections or generics. Type communicates 'this is a composition' or 'this is a variant'.
When in doubt, ask: 'Could another part of the system need to add a property to this?' If yes, interface. If no, type works fine. There's rarely a wrong choice for a pure object shape — both work — but interface gives you room to grow.
- Interface: like a legal contract — you sign it, and others can add clauses via declaration merging.
- Type: like a mathematical formula — it takes inputs (generics) and produces a single, immutable result.
- If you need to enforce a shape across your team and allow future extension, write an interface.
- If you need to express a complex transformation or a variant, write a type alias.
type for domain objects hits issues when they need to augment with analytics. A team that uses interface for everything is forced to write messy workarounds for unions. Establish a convention early: object contracts → interface, everything else → type.implements only works with interface.Partial<T>)Object Types and Interfaces: The Shape of Data in Production
Stop thinking of interfaces as class decorations. In production systems, your data flows through APIs, message queues, and state stores. Every single object needs a shape contract—otherwise you're debugging undefined at 3 AM.
Object types define the blueprint. Interfaces add the ability to extend and merge across files. When you define a UserProfile in one file and later need a UserWithPermissions that adds roles, you don't copy-paste the fields. You extend the interface.
This isn't academic. Every microservice I've worked on that skipped explicit object types ended up with runtime crashes from missing fields. TypeScript catches that at compile time—if you give it the contract. Write object types first, then watch your bug reports drop.
Classes and Object-Oriented Programming: When Interfaces Earn Their Keep
Here's the dirty secret: interfaces shine outside classes. But when you do need OOP—because sometimes the domain demands it—interfaces are your contract between implementation and consumer.
A class implements an interface. It doesn't inherit behaviour—it promises structure. This decouples your code. Swap the implementation (PostgresRepo -> RedisRepo) without touching the rest of the system. That's the whole point.
Every tech lead I've seen burn time on refactors was because classes had implicit dependencies. Interfaces make those explicit. You read the interface file and know exactly what a PaymentGateway must do. No spelunking through 400-line class files.
Arrays: Type Inference and Const Assertions for Immutable Data
TypeScript infers array types from initialization, but this often produces mutable string[] when you need fixed tuples or readonly arrays. The as const assertion locks the literal values and makes the array readonly, preventing mutations that cause runtime bugs. For tuple-like data, explicitly annotate [string, number] instead of relying on inference. Always prefer readonly arrays in function parameters to signal the caller the data won't be modified. Arrays with as const become deeply immutable, which pairs well with Redux action creators or configuration lists. Favor array methods like .map and .filter over mutations, and use [...spread] to copy. This pattern reduces accidental side effects and aligns with pure data flow in production systems.
array.push on inferred arrays. One accidental push causes subtle state bugs. Use as const or ReadonlyArray<T> from the start.as const and readonly to eliminate accidental array mutations in production.Symbol: Unique Property Keys for Object Contracts
Symbols create unique, non-string property keys that prevent naming collisions in interfaces and types. Every Symbol() call returns a new unique symbol, making it impossible for two modules to accidentally overwrite the same property. Use unique symbol type annotation in interfaces to declare a specific symbol property. This is critical for library authors exposing metadata keys or internal state on public objects. Symbols also support well-known symbols like Symbol.iterator to define iteration contracts. When combining with interfaces, declare symbol properties as readonly to prevent external reassignment. Compared to string or number keys, symbols guarantee uniqueness across module boundaries, making them ideal for framework hooks or plugin systems.
Symbol() inside an interface as a regular property type — it resolves to symbol, not unique symbol. Always declare as const with unique symbol for type safety.unique symbol in interfaces to enforce collision-proof property contracts across modules.bigint: Type Safety for Large Integer Arithmetic
The bigint type handles integers beyond 2^53, but TypeScript enforces strict type separation from number. Mixing bigint with number in operations causes compile errors — explicit conversions via Number() or BigInt() are required. Use bigint for IDs from databases, cryptocurrency amounts, or timestamps with nanosecond precision. Declare literal bigints with the n suffix: 100n. Interfaces can define bigint fields, but JSON serialization fails because JSON.stringify does not support bigint. The production solution is custom serializers (like replacer in JSON.stringify) or libraries like json-bigint. Always validate that your runtime environment supports BigInt — Node 10.4+ and modern browsers, but never in older transpiled code without polyfills.
JSON.stringify. Implement a custom replacer or serialize bigint fields as strings before transmission.bigint as a separate type: require explicit conversion to number for arithmetic and custom serialization for JSON.The `type` That Broke Library Augmentation
any casts to silence the compiler.type and interface were equivalent for object types, so they standardized on type for consistency.type aliases are closed — they cannot be merged by subsequent declarations. The third-party library's module augmentation added properties to an interface, but the original type was invisible to it.type with an interface (no code change needed — same shape). The declaration merging worked immediately, and all any casts were removed.- Use
interfacefor any type that might need augmentation — especially public APIs and component props. typeis fine for internal unions, tuples, and utility types that don't need external extension.- If you're unsure, prefer
interfacefor object shapes; you can always use atypealias for the same shape later if needed.
class Foo implements MyType — TypeScript error: 'Only interfaces can be implemented'.MyType from type to interface. Classes can only implement interfaces.type via declaration merging gets 'Duplicate identifier' error.type to an interface. Only interfaces support declaration merging.kind: 'success' | 'error'), not a general string.interface – error: 'An interface can only extend an object type or intersection of object types'.type for unions. Interface cannot express string | number or {a:1} | {b:2}.interface Props { x: number }// In another file: interface Props { y: string } // mergestype, replace with interface and keep the shape identical.Key takeaways
Common mistakes to avoid
3 patternsUsing `type` for class contracts
class UserService implements UserServiceType because classes can only implement interfaces, not arbitrary type aliases that contain union types or mapped types.Forgetting that declaration merging can cause unexpected behaviour
Using `as` type assertions instead of type guards to handle unknown data
const user = apiResponse as User and TypeScript stops complaining, but you get a runtime crash when the API returns unexpected data because you bypassed the type checker rather than validating the data.Interview Questions on This Topic
What's the practical difference between a type alias and an interface in TypeScript, and can you give a real scenario where you'd choose one over the other?
extends and implemented via implements; type aliases cannot be merged or implemented, but they can represent unions, tuples, and more complex type expressions. A real scenario: if you're defining the shape of a React component's props that other libraries might augment (like react-router's RouteComponentProps), use an interface. If you're writing a union type like type Result = 'success' | 'error', use a type.Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
That's TypeScript. Mark it forged?
8 min read · try the examples if you haven't