Skip to main content
Interfaces vs Types

Objects & Interfaces

Objects are the core of JavaScript, and TypeScript provides powerful ways to describe their shapes. Interfaces and type aliases are your primary tools for defining object structures. Think of an interface as a blueprint or contract — it does not create anything at runtime, but it defines the exact shape that any conforming object must follow. If an object claims to be a User, the interface guarantees it has an id, a name, and an email — no more guessing.

1. Interfaces

Interfaces define contracts for object shapes. They are the most common way to type objects in TypeScript, and they are one of the features teams adopt first because the payoff is immediate — autocomplete, refactoring safety, and self-documenting code.

Basic Interface

Optional Properties

Readonly Properties


2. Type Aliases vs Interfaces

Both can describe object shapes, and for everyday object typing, they are interchangeable. The “interface vs type” debate is one of the most common questions in TypeScript, and the honest answer is: it matters less than people think. That said, there are real differences that matter in specific situations.

Syntax Differences

Key Differences

When to use which?
  • Use interfaces for objects that might be extended or implemented
  • Use type aliases for unions, intersections, tuples, or complex compositions
  • Both work for most object types; pick one style and be consistent

3. Extending Interfaces

Build complex types by extending existing ones.

Single Inheritance

Multiple Inheritance

Extending Type Aliases


4. Intersection Types

Combine multiple types into one using the & operator. If interfaces are like inheriting from a parent, intersections are like mixing ingredients — you take everything from Type A and everything from Type B and combine them into a single type that has all properties from both.

Intersection with Conflicts


5. Index Signatures

Define types for objects with dynamic keys. Index signatures are essential when you do not know all the property names ahead of time — like a dictionary, a cache, or a configuration object where users can add arbitrary keys.

String Index Signature

Number Index Signature

Mixing Index Signatures with Properties

When using index signatures, all explicit properties must have types compatible with the index signature.

6. Mapped Types

Transform existing types into new ones using a for...in-style syntax at the type level. Mapped types are one of TypeScript’s most powerful features — they let you derive new types from existing ones programmatically, rather than manually rewriting every property. Think of them as a “type factory” that takes one type as input and produces a modified version as output.

Basic Mapped Type

Built-in Utility Types

TypeScript provides several utility types based on mapped types:

7. Template Literal Types

Create types from string patterns. Template literal types are one of TypeScript’s most unique features — no other mainstream language can express “a type that is any string matching this pattern.” They let you bring the same compile-time safety to strings that you have for objects and numbers.

Practical Example: CSS Properties


8. Recursive Types

Types that reference themselves. Recursive types model data structures that are inherently self-similar — trees, nested comments, JSON values, or file system directories. They are TypeScript’s answer to the question “how do I type something that can contain itself?“

9. Interface for Functions and Arrays

Interfaces can describe more than plain objects.

Function Interface

Array Interface

Hybrid Types


10. Practical Patterns

Builder Pattern

Repository Pattern


Summary

Next, we’ll explore classes and object-oriented programming in TypeScript!

Interview Deep-Dive

Strong Answer:The “always use interfaces” advice is oversimplified. Both are valid, and the right choice depends on the use case.
  • Interfaces are better when: You are defining an object shape that will be extended or implemented. Interfaces support declaration merging — you can declare the same interface name twice and TypeScript combines them. This is essential for augmenting third-party types: declare module 'express' { interface Request { user?: User } } works because of declaration merging. You cannot do this with type aliases. Interfaces also produce better error messages because they have names that appear in diagnostics.
  • Type aliases are better when: You need unions, intersections, tuples, or computed types. type Result = Success | Failure requires a type alias. Mapped types, conditional types, and template literal types all require type aliases. An interface cannot represent a union of two shapes.
  • They are equivalent when: You are defining a simple object shape that will not be extended or merged. Pick one style for consistency and move on.
  • My approach: Interfaces for entities and contracts (things that classes implement or that represent domain objects), type aliases for everything else (unions, computed types, utility types, function signatures).
Follow-up: What is declaration merging, and can it be a footgun?Declaration merging means you can write interface Window { myCustomProp: string } and it adds myCustomProp to the existing Window interface rather than replacing it. This is powerful for extending global types and third-party libraries. The footgun is accidental merging: if two files independently declare interface Config { ... } with different properties, TypeScript silently merges them. You end up with a Config that has properties from both files, which may not be your intent. Type aliases prevent this because redeclaring a type alias is a compile error. This is one argument for preferring type aliases in application code and reserving interfaces for library type augmentation.
Strong Answer:Index signatures define the type for dynamic keys on an object — keys that are not known at compile time.
  • When they are useful: Configuration objects, dictionaries, caches, and any key-value mapping where the keys come from external data. Think of a translation file: { en: 'Hello', fr: 'Bonjour', de: 'Hallo' } — you do not know all the locale codes at compile time, but you know every value is a string.
  • The biggest pitfall: false confidence in property access. When you access dict['someKey'], TypeScript returns the value type (e.g., number) without undefined, even though the key might not exist. This is TypeScript lying to you for convenience. If you enable noUncheckedIndexedAccess in tsconfig.json, TypeScript correctly returns number | undefined for index signature access, forcing you to handle the missing-key case. Without this flag, const value: number = dict['nonexistent'] compiles without error but returns undefined at runtime.
  • The compatibility constraint: All explicitly declared properties must have types compatible with the index signature. If you write interface User { id: number; [key: string]: string }, it fails because id: number is not compatible with the index signature’s value type string.
  • Alternative: Record and Map. Record<string, number> is syntactic sugar for an index signature. For runtime dynamic keys, Map<string, number> is often better because it does not conflate your data keys with object prototype properties and has proper .has() and .get() methods.
Follow-up: What does noUncheckedIndexedAccess do, and should every project enable it?It makes TypeScript add | undefined to the return type of every index signature access and array index access. This is technically correct — arrays can be empty — but it requires nullish checks on every array access. My recommendation: enable it for new projects handling external data. For existing projects, the migration cost may not justify the safety gain.
Strong Answer:This is a subtle behavior that trips up even experienced TypeScript developers.
  • Compatible properties: merged normally. { name: string } & { age: number } produces { name: string; age: number }.
  • Identical properties: collapsed. { name: string } & { name: string } produces { name: string }.
  • Conflicting properties: resolved to never. { value: string } & { value: number } produces { value: string & number }, which is { value: never }. There is no value that is both a string and a number, so the intersection is never. You can declare a variable of this type, but you can never create a valid value for it.
  • Why this matters in practice: It comes up when composing types from different sources. If a library exports type WithId = { id: string } and your codebase has type Entity = { id: number }, intersecting them produces { id: never } — silently unusable. The fix is ensuring compatible types before intersecting, or using Omit to remove conflicting properties first.
  • Method signatures are different: If both types have a method with the same name but different signatures, the intersection creates an overloaded method. This is intentional and useful for composing interfaces with polymorphic methods.
Follow-up: How does this differ from union types with conflicting properties?With unions, TypeScript takes the union of the property types. For { value: string } | { value: number }, accessing value gives you string | number — because the value could come from either variant. You must narrow before using variant-specific methods. The key insight: intersection types combine the properties (you have ALL of them), while union types restrict to common properties (you can only access what is guaranteed across ALL variants).
Strong Answer:The Repository pattern is one of the most common interview design exercises, and the type design reveals how well you understand generics and utility types.
  • Base interface with two type parameters: interface Repository<T, ID> where T is the entity type and ID is the identifier type. Two parameters because not every entity uses the same ID type.
  • Create signature using Omit: create(entity: Omit<T, 'id' | 'createdAt' | 'updatedAt'>): Promise<T>. The caller should not provide auto-generated fields. Omit removes them from the input type while the return type is the full entity.
  • Update signature using Partial: update(id: ID, entity: Partial<T>): Promise<T>. Partial updates are the norm — you rarely send every field.
  • FindById returns nullable: findById(id: ID): Promise<T | null>. Not T | undefined — null is the conventional “not found” signal in database contexts.
  • Constrain T with a base entity: T extends { id: ID } ensures every entity has an id field. This lets the repository implementation rely on entity.id without asserting.
The practical nuance: in a real codebase, you would separate the “input” type from the “stored” type. Utility types (Omit, Partial, Pick) derive these from a single source-of-truth type, so you maintain one definition and get all the variants automatically.Follow-up: How would you make this repository pattern testable? Would you use interfaces or abstract classes?I would define the repository as an interface, not an abstract class. Interfaces have no runtime presence — they are pure contracts. For testing, I create a mock implementation that stores data in a plain array without pulling in database dependencies. For production, a real implementation uses the database. The consuming code depends on the interface, not the implementation, following dependency inversion. Abstract classes would work but are heavier — they carry implementation code and cannot be multiply inherited. For a pure data-access contract, interfaces are the right tool.