`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.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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.
TypeScript gives you two primary tools for defining object shapes: type aliases and interfaces. While they often seem interchangeable, they have fundamentally different behaviors that matter in production code. A type alias creates a static, immutable snapshot of any shape—primitives, unions, tuples, or complex intersections.
Once defined, it cannot be reopened or extended. An interface, by contrast, is a contract that can be augmented through declaration merging, making it the only choice for library augmentation (e.g., adding properties to window or extending third-party types like Express.Request).
This distinction is critical because type breaks library augmentation entirely. If you define type Foo = { bar: string }, no other file can add baz to Foo—you'd need to use intersection types (&) and manually reassign. Interfaces, however, automatically merge: interface Foo { bar: string } followed by interface Foo { baz: number } results in a single Foo with both properties.
This is why DefinitelyTyped and major libraries like React, Express, and Mongoose use interfaces for their public APIs.
In practice, you reach for type when you need union types (type Status = 'active' | 'inactive'), mapped types, or conditional types—things interfaces cannot express. You use interface for object shapes that might need extension, especially in library code or when consuming third-party types.
The rule of thumb from production codebases: prefer interface for public APIs and object contracts; use type for everything else that isn't an object shape.
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.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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.
// ── 1. Aliasing a union type ────────────────────────────────────────────── // This says: a Status can ONLY be one of these three strings. // TypeScript will error if you try to assign anything else. type Status = 'idle' | 'loading' | 'success' | 'error'; // ── 2. Describing a function signature ──────────────────────────────────── // Anyone reading this immediately knows what this callback must look like. // No more guessing what arguments an event handler receives. type PriceFormatter = (amount: number, currencyCode: string) => string; const formatUSD: PriceFormatter = (amount, currencyCode) => { // Intl.NumberFormat gives us locale-aware currency formatting return new Intl.NumberFormat('en-US', { style: 'currency', currency: currencyCode, }).format(amount); }; console.log(formatUSD(1999.5, 'USD')); // $1,999.50 // ── 3. Describing a complex object shape ────────────────────────────────── type ApiResponse<T> = { data: T | null; // the actual payload, or null if there's an error status: Status; // reusing our union type from above errorMessage?: string; // the ? makes this property optional requestedAt: Date; }; // A concrete usage — an API response that carries a list of product names const productResponse: ApiResponse<string[]> = { data: ['Wireless Keyboard', 'USB Hub', 'Monitor Stand'], status: 'success', requestedAt: new Date('2024-06-01T10:00:00Z'), }; console.log(productResponse.status); // success console.log(productResponse.data?.length); // 3 // ── 4. Tuple type — positional, fixed-length array ──────────────────────── // Use this when the ORDER and TYPES of positions are meaningful. // Here: [latitude, longitude] — never the other way around. type Coordinates = [latitude: number, longitude: number]; const sydneyHarbour: Coordinates = [-33.8568, 151.2153]; console.log(`Lat: ${sydneyHarbour[0]}, Lng: ${sydneyHarbour[1]}`); // Lat: -33.8568, Lng: 151.2153
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.
// ── 1. Base interface for any entity stored in the database ─────────────── interface BaseEntity { id: string; // UUID from the database createdAt: Date; updatedAt: Date; } // ── 2. Extending an interface — User inherits all of BaseEntity ─────────── // The extends keyword says: a User must satisfy BaseEntity PLUS these fields. interface User extends BaseEntity { email: string; displayName: string; isEmailVerified: boolean; } // ── 3. Multi-level extension ─────────────────────────────────────────────── // AdminUser must satisfy User (which itself satisfies BaseEntity) plus this. interface AdminUser extends User { permissions: string[]; // e.g. ['users:delete', 'billing:read'] lastLoginAt: Date; } // ── 4. Declaration merging — extending an interface from a separate location // Imagine this second block is in a different file, e.g. analytics.d.ts // TypeScript merges BOTH declarations into one complete interface. interface User { analyticsId?: string; // added by your analytics module — optional } // Now a User object can legally include analyticsId const currentUser: User = { id: 'usr_8f3kd92', createdAt: new Date('2023-01-15'), updatedAt: new Date('2024-05-20'), email: 'priya@example.com', displayName: 'Priya Sharma', isEmailVerified: true, analyticsId: 'ga_4829xz', // merged from second declaration }; console.log(currentUser.displayName); // Priya Sharma console.log(currentUser.analyticsId); // ga_4829xz // ── 5. Interface for a class contract ──────────────────────────────────── // This is where interface is strictly more appropriate than type. // The implements keyword enforces the contract at the class level. interface NotificationService { send(recipient: string, message: string): Promise<void>; getDeliveryStatus(messageId: string): Promise<'delivered' | 'failed' | 'pending'>; } class EmailNotificationService implements NotificationService { async send(recipient: string, message: string): Promise<void> { // Real implementation would call an email API here console.log(`Sending email to ${recipient}: "${message}"`); } async getDeliveryStatus(messageId: string): Promise<'delivered' | 'failed' | 'pending'> { // Real implementation would query an email delivery API console.log(`Checking status for message: ${messageId}`); return 'delivered'; } } const emailService = new EmailNotificationService(); emailService.send('alex@example.com', 'Your order has shipped!'); // Sending email to alex@example.com: "Your order has shipped!"
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.'
// ── 1. Intersection type — combining two shapes into one ────────────────── type Timestamped = { createdAt: Date; updatedAt: Date; }; type Identifiable = { id: string; }; // A Product must satisfy BOTH Timestamped AND Identifiable, plus its own fields type Product = Timestamped & Identifiable & { name: string; priceInCents: number; // storing money as integers avoids floating-point bugs stockCount: number; }; const wirelessMouse: Product = { id: 'prod_wm_001', name: 'Wireless Ergonomic Mouse', priceInCents: 4999, // represents $49.99 stockCount: 142, createdAt: new Date('2024-01-10'), updatedAt: new Date('2024-06-01'), }; console.log(`${wirelessMouse.name}: $${wirelessMouse.priceInCents / 100}`); // Wireless Ergonomic Mouse: $49.99 // ── 2. Generic type — a reusable wrapper for paginated API responses ─────── // The <T> is a placeholder. When you USE this type, you fill in what T is. type PaginatedResult<T> = { items: T[]; // an array of whatever type T turns out to be totalCount: number; currentPage: number; totalPages: number; hasNextPage: boolean; }; // Now reuse it for any entity — no duplication type PaginatedProducts = PaginatedResult<Product>; type PaginatedUsers = PaginatedResult<{ id: string; email: string }>; const productPage: PaginatedProducts = { items: [wirelessMouse], totalCount: 87, currentPage: 1, totalPages: 9, hasNextPage: true, }; console.log(`Page ${productPage.currentPage} of ${productPage.totalPages}`); // Page 1 of 9 // ── 3. Generic function — works with any entity that has an id ───────────── // The constraint 'extends Identifiable' means: T must have at least an id. // This prevents calling findById with a plain number or a string. function findById<T extends Identifiable>(items: T[], targetId: string): T | undefined { // TypeScript knows items[i].id is safe because of the extends constraint return items.find(item => item.id === targetId); } const foundProduct = findById([wirelessMouse], 'prod_wm_001'); console.log(foundProduct?.name); // Wireless Ergonomic Mouse // ── 4. Using built-in utility types ─────────────────────────────────────── // Partial<T> makes every property optional — perfect for update/patch payloads type ProductUpdatePayload = Partial<Pick<Product, 'name' | 'priceInCents' | 'stockCount'>>; // A PATCH request only needs to send the fields being changed const priceUpdate: ProductUpdatePayload = { priceInCents: 3999, // just updating the price — all other fields are optional }; console.log(priceUpdate); // { priceInCents: 3999 }
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.'
// ── Our domain types ────────────────────────────────────────────────────── type SuccessResponse = { kind: 'success'; // discriminant field — a literal type, not just string data: { orderId: string; total: number }; }; type ErrorResponse = { kind: 'error'; // different literal — same field name, different value message: string; code: number; }; // A discriminated union — both members share 'kind' but with different values type CheckoutResponse = SuccessResponse | ErrorResponse; // ── 1. Discriminated union narrowing — the cleanest pattern ─────────────── // TypeScript uses the 'kind' field to automatically narrow the type in each branch. function handleCheckoutResponse(response: CheckoutResponse): void { if (response.kind === 'success') { // Inside this block, TypeScript KNOWS response is SuccessResponse // So response.data is available and type-safe console.log(`Order confirmed! ID: ${response.data.orderId}`); console.log(`Total charged: $${response.data.total / 100}`); } else { // In the else block, TypeScript KNOWS response is ErrorResponse console.log(`Checkout failed [${response.code}]: ${response.message}`); } } handleCheckoutResponse({ kind: 'success', data: { orderId: 'ord_7f4kx', total: 8997 }, }); // Order confirmed! ID: ord_7f4kx // Total charged: $89.97 handleCheckoutResponse({ kind: 'error', message: 'Card declined', code: 4001, }); // Checkout failed [4001]: Card declined // ── 2. User-defined type guard — for validating unknown external data ────── // The return type 'value is SuccessResponse' is the magic. // When this function returns true, TypeScript narrows the type in the calling scope. function isSuccessResponse(value: unknown): value is SuccessResponse { // We manually check every field we care about return ( typeof value === 'object' && value !== null && 'kind' in value && (value as SuccessResponse).kind === 'success' && 'data' in value ); } // Simulating data coming back from fetch() — typed as unknown const rawApiData: unknown = { kind: 'success', data: { orderId: 'ord_9g5kp', total: 4500 }, }; if (isSuccessResponse(rawApiData)) { // TypeScript now trusts that rawApiData is SuccessResponse console.log(`Validated order: ${rawApiData.data.orderId}`); } else { console.log('Received unexpected response shape from API'); } // Validated order: ord_9g5kp
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.
// ── Production rule of thumb ───────────────────────────────────────────── // Domain entity → interface interface User { id: string; email: string; } // Utility/union → type type UserUpdatePayload = Partial<Pick<User, 'email'>>; // Function type → type type UserFetcher = (id: string) => Promise<User>; // Class contract → interface interface Repository<T> { find(id: string): Promise<T | undefined>; save(entity: T): Promise<void>; } // Works both ways, but prefer interface for extensibility class UserRepository implements Repository<User> { async find(id: string): Promise<User | undefined> { // ... } async save(entity: User): Promise<void> { // ... } } // ── When you need both: type for union, interface for base shape ────────── interface BaseAPIResult { timestamp: Date; } type APIResult<T> = BaseAPIResult & { data: T | null; error: string | null; }; // This pattern is common — interface for the fixed contract, type for the variant part.
- 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.
// io.thecodeforge — javascript tutorial interface UserProfile { id: string; email: string; displayName: string; createdAt: Date; } interface UserWithPermissions extends UserProfile { roles: Array<'admin' | 'editor' | 'viewer'>; lastLoginIp: string; } function sendWelcomeEmail(user: UserProfile): void { console.log(`Emailing ${user.email}...`); } const adminUser: UserWithPermissions = { id: 'u-789', email: 'ops@acme.co', displayName: 'Ops Lead', createdAt: new Date('2024-01-15'), roles: ['admin', 'editor'], lastLoginIp: '192.168.1.10' }; sendWelcomeEmail(adminUser); // Works — extends contract
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.
// io.thecodeforge — javascript tutorial interface PaymentGateway { charge(amount: number, currency: string): Promise<TransactionResult>; refund(transactionId: string): Promise<RefundStatus>; } interface TransactionResult { success: boolean; transactionId: string; errorMessage?: string; } interface RefundStatus { completed: boolean; refundedAt: Date; } class StripeGateway implements PaymentGateway { async charge(amount: number, currency: string): Promise<TransactionResult> { console.log(`Charging $${amount} ${currency} via Stripe...`); return { success: true, transactionId: 'txn_stripe_123' }; } async refund(transactionId: string): Promise<RefundStatus> { console.log(`Refunding ${transactionId}...`); return { completed: true, refundedAt: new Date() }; } } const payment: PaymentGateway = new StripeGateway(); const result = await payment.charge(49.99, 'USD'); console.log(result.success);
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.
// io.thecodeforge — javascript tutorial // Inferred mutable array const colors = ["red", "green", "blue"]; // Tuple with fixed types const rgb: [number, number, number] = [255, 0, 0]; // Immutable readonly array with const assertion const fixedColors = ["red", "green", "blue"] as const; // Error: Index signature in type 'readonly ["red", "green", "blue"]' only permits reading. // fixedColors.push("yellow"); function processItems(items: readonly string[]): string[] { return items.map(item => item.toUpperCase()); }
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.
// io.thecodeforge — javascript tutorial const metadataKey: unique symbol = Symbol("metadata"); interface Entity { id: string; [metadataKey]: Record<string, unknown>; } const user: Entity = { id: "123", [metadataKey]: { createdAt: new Date() } }; // Access via symbol only – no string collision console.log(user[metadataKey]); // No other code can accidentally overwrite the metadata property // Symbol.iterator for custom iteration const iterable = { *[Symbol.iterator]() { yield 1; yield 2; }, };
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.
// io.thecodeforge — javascript tutorial const maxSafe: number = Number.MAX_SAFE_INTEGER; // 9007199254740991 const bigId: bigint = 9007199254740992n; interface Order { id: bigint; amount: number; } const order: Order = { id: 12345678901234567890n, amount: 250 }; // Error: Operator '+' cannot be applied to types 'bigint' and 'number' // const total = order.id + order.amount; // Correct: explicit conversion const total = Number(order.id) + order.amount; // JSON serialization fails without replacer const json = JSON.stringify(order, (key, value) => typeof value === "bigint" ? value.toString() : value );
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.type Result = Success | Error// Interface can't represent unionsinterface that you want to union, you must refactor to type.type Point = [x: number, y: number]// Interface cannot define tuple membersinterface to type.interface Service { execute(): void }class MyService implements Service { ... }type that a class implements, change it to interface.| Feature / Capability | type alias | interface |
|---|---|---|
| Describe an object shape | Yes | Yes |
| Union types (A | B) | Yes — primary use case | No — not supported |
| Intersection / merging | Yes — using & | Yes — using extends |
| Declaration merging | No — causes a compile error | Yes — core feature |
| Extend / implement in a class | No — classes can't implement type aliases | Yes — implements keyword |
| Tuple types | Yes | No |
| Function signatures | Yes — first-class citizen | Yes — but more verbose |
| Generic parameters | Yes | Yes |
| Computed property keys | Yes — via mapped types | Limited support |
| Recommended for domain entities | Situational | Yes — idiomatic choice |
| Recommended for utility/union types | Yes — idiomatic choice | No |
| File | Command / Code | Purpose |
|---|---|---|
| typeAliasExamples.ts | type Status = 'idle' | 'loading' | 'success' | 'error'; | Type Aliases |
| interfaceExamples.ts | interface BaseEntity { | Interfaces |
| intersectionAndGenerics.ts | type Timestamped = { | Intersection Types and Generics |
| typeGuards.ts | type SuccessResponse = { | Type Guards |
| decisionGuide.ts | interface User { | When to Choose type vs interface |
| UserProfileContract.js | interface UserProfile { | Object Types and Interfaces |
| PaymentGatewayContract.js | interface PaymentGateway { | Classes and Object-Oriented Programming |
| ArrayTypes.ts | const colors = ["red", "green", "blue"]; | Arrays |
| SymbolKeys.ts | const metadataKey: unique symbol = Symbol("metadata"); | Symbol |
| BigIntUsage.ts | const maxSafe: number = Number.MAX_SAFE_INTEGER; // 9007199254740991 | bigint |
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.Explain declaration merging in TypeScript interfaces. Where have you used it or seen it used in a real codebase?
interface Request that you can augment in a .d.ts file to add currentUser property. In our codebase, we used it to add analyticsId to a shared User interface from an analytics module without modifying the user module.What is a discriminated union and how does it relate to type narrowing? Why is the discriminant field's type significant?
'success') not a general string, because TypeScript relies on the exact value to know which variant we have. If the discriminant is typed as string, narrowing doesn't work.Can a type alias extend an interface? How would you do it?
type AdminUser = User & { permissions: string[] }. This creates a type alias that is effectively an extension of the interface. However, the resulting type cannot be merged further or implemented by a class. If you need class implementation, use interface extends instead.Frequently Asked Questions
For most object shapes representing domain entities — User, Product, Order — interface is the idiomatic choice. It supports extends, declaration merging, and the implements keyword in classes. Use type when you need a union, a tuple, a mapped type, or a computed property key, since interface doesn't support those.
Yes, using an intersection type. Write 'type AdminUser = User & { permissions: string[] }' to create a type alias that combines an interface with additional properties. The reverse also works — an interface can extend a type alias using the extends keyword, as long as the type alias describes an object shape (not a union or primitive).
It refers to declaration merging. An interface is 'open' because you can declare it again anywhere in your codebase and TypeScript merges the declarations — it's extensible by design. A type alias is 'closed' because redeclaring the same type name is a compile error. This openness is why library authors use interfaces for their public APIs — it lets consumers add properties via module augmentation without forking the library.
No. If you try to declare the same type alias name twice, TypeScript reports a 'Duplicate identifier' error. Only interfaces support declaration merging. This is the key differentiator when you need extensibility.
Use interface extends when you're building an explicit type hierarchy where all members are object shapes — it's more readable and supports further extension. Use intersection types when you want to compose unrelated types, or when the result combines types that include unions, primitives, or non-object shapes. Both produce the same structure, but semantics differ.
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
That's TypeScript. Mark it forged?
6 min read · try the examples if you haven't