Skip to main content
PProfileMatch

TypeScript Interfaces, Type Aliases and Union Types

ProgrammingLesson 2 of 45 min read

What you'll learn

  • Describe object shapes with interfaces and type aliases and know how they differ
  • Combine types with union and intersection operators
  • Build a discriminated union and handle every case with an exhaustive switch
  • Use readonly and optional properties to make invalid states harder to write

On this page

  1. Interfaces and type aliases: two ways to name a shape
  2. A discriminated union for the support desk
  3. What the compiler says when you break the model
  4. Common mistakes and the errors they produce
  5. How this shows up in real projects and interviews

How would you represent a customer-support timeline? Tickets get opened, replied to and closed, and each event carries different data: an opened event has a subject, a reply has an agent and a response time, a closure has a resolution. A beginner's first draft is one big object with every field marked optional, and then every piece of code that reads it has to guess which fields are present. TypeScript lets you say it properly: an event is one of three precise shapes, and the compiler checks that you handle each one.

Interfaces and type aliases: two ways to name a shape

An interface describes the shape of an object: its property names, their types, which are optional (?) and which cannot be reassigned (readonly). A type alias gives a name to any type at all, including unions, tuples and primitives. For plain object shapes the two are almost interchangeable, so teams pick a convention. A common one is interfaces for object shapes that other code might extend, type aliases for everything else.

interfacetype alias
Object shapesYesYes
Unions, tuples, primitives, mapped typesNoYes
Extending another typeextends& (intersection)
Declaring the same name twiceMerges the declarationsError: duplicate identifier
Typical usePublic object contracts, class shapesUnions, utility combinations, function types

Optional properties and unions interact in a way worth knowing. note?: string means the property may be missing entirely, and reading it gives string | undefined. A property declared note: string | undefined must be present but may hold undefined. Most of the time you want the first form for data that genuinely may be absent. Prefer separate interfaces over a pile of optionals when the optional fields only make sense together, because the union lets the compiler enforce that.

A discriminated union for the support desk

A union type A | B | C means a value is one of those types. When every member has a property with a unique literal value, here kind, TypeScript can tell the members apart by checking that property. That is called a discriminated union, and it is one of the most useful patterns in the language.

desk.ts
// desk.ts - events on a support desk, as a discriminated union
type Priority = "low" | "normal" | "urgent";

interface TicketBase {
  readonly id: number;
  customer: string;
  priority: Priority;
  note?: string;
}

interface Opened extends TicketBase {
  kind: "opened";
  subject: string;
}
interface Replied extends TicketBase {
  kind: "replied";
  agent: string;
  minutesToReply: number;
}
interface Closed extends TicketBase {
  kind: "closed";
  resolution: "fixed" | "wont-fix" | "duplicate";
}

type TicketEvent = Opened | Replied | Closed;

function summarise(event: TicketEvent): string {
  switch (event.kind) {
    case "opened":
      return `#${event.id} opened by ${event.customer}: ${event.subject}`;
    case "replied":
      return `#${event.id} answered by ${event.agent} after ${event.minutesToReply} min`;
    case "closed":
      return `#${event.id} closed (${event.resolution})`;
    default: {
      const unreachable: never = event;
      return unreachable;
    }
  }
}

const feed: TicketEvent[] = [
  { kind: "opened", id: 41, customer: "Okafor Ltd", priority: "urgent", subject: "Cannot export invoices" },
  { kind: "replied", id: 41, customer: "Okafor Ltd", priority: "urgent", agent: "Priya", minutesToReply: 12 },
  { kind: "closed", id: 41, customer: "Okafor Ltd", priority: "urgent", resolution: "fixed" },
];

for (const event of feed) console.log(summarise(event));

const urgentCount = feed.filter((e) => e.priority === "urgent").length;
console.log("urgent events:", urgentCount);

type Audited = TicketEvent & { auditedBy: string };
const audited: Audited = { ...feed[2], auditedBy: "Lena" };
console.log(audited.kind, audited.auditedBy);

Output

#41 opened by Okafor Ltd: Cannot export invoices
#41 answered by Priya after 12 min
#41 closed (fixed)
urgent events: 3
closed Lena

Inside each case, TypeScript narrows event to the matching member, so event.subject is allowed under "opened" and a typo or the wrong property is an error. Properties that exist on every member, like priority, can be used without narrowing. The default branch assigns event to a variable of type never: if every case is handled, nothing is left, so it compiles. This is an exhaustiveness check. Add a fourth event type later and every switch that forgot it stops compiling, which turns "did I update everything?" into a compiler job.

The & operator builds an intersection: TicketEvent & { auditedBy: string } has everything from the event and the extra field. Use unions when a value is one of several shapes, and intersections when a value must satisfy several shapes at once. Note that the example runs with node --experimental-strip-types because it uses only type syntax.

What the compiler says when you break the model

Suppose the desk adds an "escalated" event but nobody updates the switch, and a developer also makes a few typical slips. This is wrong on purpose; the messages are real TypeScript 5.9 output for the lines described.

breaking_the_model.ts (does not compile)
interface Escalated extends TicketBase { kind: "escalated"; to: string }
type TicketEvent = Opened | Replied | Closed | Escalated;

// in summarise(): the default branch now fails           (1)
const t: Opened = { kind: "opened", id: 1, customer: "x", priority: "urgent", subject: "s" };
t.id = 2;                                                  // (2)
t.priority = "high";                                       // (3)
function agentOf(e: TicketEvent) { return e.agent; }      // (4)
const u: TicketEvent = { kind: "opened", id: 1, customer: "x", priority: "low" };   // (5)

Output

(1) Type 'Escalated' is not assignable to type 'never'.
(2) Cannot assign to 'id' because it is a read-only property.
(3) Type '"high"' is not assignable to type 'Priority'.
(4) Property 'agent' does not exist on type 'TicketEvent'.
      Property 'agent' does not exist on type 'Opened'.
(5) Property 'subject' is missing in type '{ kind: "opened"; id: number; customer: string; priority: "low"; }' but required in type 'Opened'.

Common mistakes and the errors they produce

  • One big object with every field optional. Every consumer has to guess which fields exist, and you get 'agent' is possibly 'undefined' everywhere. Use a union of precise shapes.
  • A discriminant typed as `string`. If kind is string instead of a literal such as "opened", narrowing does nothing and you get Property 'subject' does not exist on type. Make it a literal type.
  • Reading a member-only property without narrowing. e.agent on the whole union gives Property 'agent' does not exist on type 'TicketEvent'. Check e.kind first.
  • Mixing up `|` and `&`. A & B demands both shapes at once; two object types with conflicting property types can produce never. Reach for & only when you mean "both".
  • Assuming `readonly` freezes at runtime. It is a compile-time check only, and it is shallow: a readonly array property can still be pushed to unless you use readonly string[].
  • Relying on declaration merging by accident. Two interfaces with the same name are silently merged. If that surprises you, use a type alias, which reports a duplicate.

How this shows up in real projects and interviews

Discriminated unions show up wherever data has distinct states: API results ({ ok: true, data } | { ok: false, error }), UI loading states, reducer actions in Redux or React's useReducer, and event buses. They replace flags like isLoading plus error plus data that can contradict each other. Interviewers often ask "interface or type?" and want a reasoned convention, not a rule; a good answer mentions extension, unions and merging. See how this feeds into component props in React components and props. Next, make this kind of code reusable across types with generics.

Try it yourself

Model an API result as a discriminated union: { status: "loading" } | { status: "success"; items: string[] } | { status: "error"; message: string; retryable: boolean }. Write render(result) that returns a different string for each, with an exhaustive switch. Then add a fourth state, "empty", and confirm the compiler tells you which function to update.

Show a hint

Declare the three shapes as separate interfaces or inline types under one type Result = ..., and use the const unreachable: never = result pattern in the default branch.

Key takeaways

  • Use interface for object shapes and type for unions, tuples and combinations; be consistent within a team.
  • A union means "one of these"; an intersection means "all of these at once".
  • Give each union member a literal kind property so the compiler can narrow it in switch and if.
  • Assign the leftover value to never in default to get exhaustiveness checking.
  • readonly and ? make invalid states harder to write, but they exist only at compile time.

Test yourself

A trick from this lesson's ride: A ? property is a drawer that might be empty: look before you use it Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

What is the difference between interface and type in TypeScript?

Both can describe object shapes. Interfaces can be extended with extends and merge when declared twice, while type aliases can also name unions, tuples and primitives and are combined with &. Many teams use interfaces for object contracts and type for everything else.

What is a discriminated union in TypeScript?

It is a union of object types that each have a property with a unique literal value, such as kind: "opened". Checking that property in an if or switch lets TypeScript narrow the value to exactly one member, so you can safely use that member's own properties.

How do I make a TypeScript switch statement exhaustive?

In the default branch, assign the switched value to a variable of type never, for example const unreachable: never = event. If every union member is handled, that compiles; if a case is missing, the compiler reports an error naming the unhandled type.