Interface vs Type Alias — When to Use Which

7 min read

You've now used both tools to describe object shapes. interface User { ... } and type User = { ... } look almost identical, do almost the same thing, and confuse almost everyone the first time they meet both. This lesson is the deciding guide: what each can do that the other can't, the practical rule QA teams converge on, and the cases where the choice actually matters.

They look identical for object shapes

Side by side, the two ways to describe the same shape:

interface User {
  id: number;
  name: string;
  email: string;
}
 
type UserAlt = {
  id: number;
  name: string;
  email: string;
};
 
const a: User = { id: 1, name: "Alice", email: "a@test.com" };
const b: UserAlt = { id: 1, name: "Alice", email: "a@test.com" };

For describing an object, you can swap one for the other and your code keeps working. The differences only show up at the edges.

What interfaces can do that type aliases can't

Declaration merging

You can declare an interface with the same name multiple times in one project. TypeScript merges them into a single interface with all the fields combined.

interface User { name: string }
interface User { email: string }
 
const u: User = { name: "Alice", email: "alice@test.com" };
// User has BOTH fields — the two interface declarations merged

type aliases throw an error if you try the same:

type UserAlt = { name: string };
type UserAlt = { email: string };
// ❌ Duplicate identifier 'UserAlt'.

This sounds obscure until you write Cypress custom commands. The Cypress Chainable interface lives in cypress/types, and you extend it from your own file by declaring it again:

declare global {
  namespace Cypress {
    interface Chainable {
      loginAs(role: "admin" | "tester"): Chainable<void>;
    }
  }
}

That extra Chainable block merges with Cypress's official one. Now cy.loginAs("admin") is typed everywhere in your project. You cannot do this with type — declaration merging is the killer feature interfaces have.

A familiar extends keyword

You used extends last lesson to compose interfaces. type aliases compose with & instead — equivalent in result but less self-documenting. Inheritance trees in interface form read more naturally.

What type aliases can do that interfaces can't

Union types

type Status = "passed" | "failed" | "skipped";
type Id = string | number;
type LoadResult = "loading" | { data: User } | Error;

Interfaces only describe object shapes — you cannot express "a string OR a number" with interface. Anywhere you reach for |, type is the only tool.

Primitive aliases

type UserId = string;
type DurationMs = number;

These give a name to a primitive — something interface simply cannot do.

Tuples, mapped types, conditional types

The advanced toolbox of TypeScript — chapter 6 territory — only works with type. Mapped types like Readonly<T> and conditional types like T extends Error ? never : T are defined with type, never with interface.

A practical rule for QA engineers

Pick one as your default and keep going. Two reasonable defaults:

Rule A — interface for objects, type for everything else (the one many large codebases use):

  • Use interface for object shapes that might grow, get extended, or get augmented (page objects, test data, API responses, config).
  • Use type for unions, literals, primitives, tuples, and utility-type composition (Status, UserId, Readonly<...>).

Rule B — type everywhere (popular in newer codebases):

  • Use type for everything. Reach for interface only when you need declaration merging (Cypress augmentation, library type extensions).

Either rule produces a readable codebase. Don't agonise — pick one, document it in your repo's style guide, and move on. The lasting harm comes from inconsistency, not from picking the "wrong" default.

Where the choice actually matters

Three cases where switching between them is wrong, not just stylistic:

  • Augmenting a third-party library type. Cypress, Playwright, Express — every framework that exposes types you can extend uses interface. To add to them, you must use interface. type won't merge.
  • Describing a union or a primitive. type Result = Success | Failure — interface can't express this. Don't try.
  • Defining a utility type or mapped type. type Nullable<T> = T | null — interface can't express this either.

For "is this user object okay?" — the everyday case in test code — both work, and either is a fine choice.

Side by side

interface vs type — capabilities, not opinions

interface

  • Object shapes — every feature works

  • extends keyword for inheritance

  • Declaration merging — the killer feature for Cypress augmentation

  • Cannot describe unions, primitives, or tuples

  • Cannot do mapped or conditional types

type alias

  • Object shapes — works just as well

  • Composes with & instead of extends

  • Unions: type Status = "passed" | "failed"

  • Primitives: type UserId = string

  • Mapped, conditional, tuples — only type does these

Where you'll see each in test code

Interface — Cypress custom commands:

// cypress/support/index.d.ts
declare global {
  namespace Cypress {
    interface Chainable {
      loginAs(role: "admin" | "tester"): Chainable<void>;
      seedFixture(name: string): Chainable<void>;
    }
  }
}

The interface Chainable block merges with Cypress's own. Replacing this with type breaks the augmentation entirely.

Interface — Playwright fixtures:

import { test as base } from "@playwright/test";
 
interface Fixtures {
  authenticatedPage: Page;
  apiClient: ApiClient;
}
 
export const test = base.extend<Fixtures>({ /* ... */ });

Type alias — closed sets and IDs:

type Severity = "critical" | "high" | "medium" | "low";
type TestId = string;
type ApiResult<T> = { ok: true; data: T } | { ok: false; error: string };

The pattern that emerges: interfaces describe the shape of objects you'll instantiate; types describe everything else, especially closed value sets and helper type expressions.

A small QA-shaped example using both

type Severity = "critical" | "high" | "medium" | "low";
type ChannelName = "pager" | "slack" | "email" | "dashboard";
 
interface AlertRule {
  id: string;
  severity: Severity;
  channel: ChannelName;
  enabled: boolean;
}
 
interface AdminAlertRule extends AlertRule {
  approvedBy: string;
}
 
const rule: AdminAlertRule = {
  id: "rule-42",
  severity: "high",
  channel: "slack",
  enabled: true,
  approvedBy: "alice",
};

Severity and ChannelName are unions — type is the only option. AlertRule and AdminAlertRule are object shapes that benefit from extends — interfaces fit naturally. Mixing both isn't a contradiction; it's how the two tools fit together.

⚠️ Common mistakes

  • Reaching for type to describe Cypress augmentation. It won't merge with the official Cypress types — your custom command will type-check inside your own file but not be picked up globally. Use interface for any framework-augmentation code.
  • Trying to put a union in an interface. interface Result = Success | Failure is a syntax error — interface can only describe object shapes. Switch to type and the same idea works.
  • Endlessly debating which to use. The cost of picking the "wrong" one for an object shape is zero — both work, both refactor, both look the same in tooltips. The cost of inconsistency across a codebase is real (every file has a different convention). Pick one rule, write it in your style guide, stop debating.

🎯 Practice task

Make the choice in code. 20-30 minutes.

  1. In your ts-for-qa/src folder, create mixed.ts.
  2. Declare three type aliases for closed sets and IDs:
    • type Severity = "critical" | "high" | "medium" | "low"
    • type ChannelName = "pager" | "slack" | "email" | "dashboard"
    • type RuleId = string
  3. Declare two interfaces using those types:
    • interface AlertRule { id: RuleId; severity: Severity; channel: ChannelName; enabled: boolean }
    • interface AdminAlertRule extends AlertRule { approvedBy: string }
  4. Create one value of each. Run with npx ts-node src/mixed.ts.
  5. Try the wrong tool for each job. After each, read the error and revert:
    • Replace type Severity = ... with interface Severity = "critical" | "high". (Syntax error — interfaces can't be unions.)
    • Replace interface AlertRule extends ... { approvedBy: string } with type AlertRule = AlertRule & { approvedBy: string }. (Self-referential type error.)
  6. Try declaration merging. Add a second interface AlertRule { tags: string[] } block in the same file. Now your AlertRule requires tags everywhere — read the errors at every existing value. Revert.
  7. Stretch: simulate a Cypress augmentation. Declare a fake namespace Cypress { interface Chainable { loginAs(role: Severity): void } } and confirm the interface is what makes the merge work. Try changing it to type and watch the merge break.

That wraps up chapter 3. You can describe any QA data shape now — required and optional fields, readonly contracts, inheritance hierarchies, and the right tool for each. The next chapter shifts focus from shapes to behaviour: typing functions.

// tip to track lessons you complete and pick up where you left off across devices.