Skip to main content
ANVISoftware Solutions
Lesson 18 of 20Intermediate16 min

Interfaces and Type Aliases

By the end of this lesson

Describe the shape of objects and function signatures.

Most of the values in an application are objects with a known shape. An employee has an id, a name and a department. An expense has an amount and a status.

Writing that shape down once gives you two things: the checker can verify every place the object is built or read, and the next person has a definition to look at instead of guessing from the code that happens to use it.

Describing an employee
TypeScript
interface Employee {
  readonly id: string;
  name: string;
  department: string;
  startDate: string;
  managerName?: string;
  isActive: boolean;
}

const priya: Employee = {
  id: "E-00417",
  name: "Priya Raman",
  department: "Finance",
  startDate: "2021-04-12",
  isActive: true,
};

priya.department = "Payroll";   // fine
priya.id = "E-00418";           // Error: id is readonly

function describe(employee: Employee): string {
  return employee.name + ", " + employee.department;
}
  • An interface lists the properties and their types. It describes a shape; it creates nothing at run time.
  • readonly on id says this value is set when the record is created and never changed afterwards. Attempting it is a compile error.
  • The question mark on managerName makes it optional. Its type becomes string | undefined, so the checker will not let you use it without handling the absence.
  • priya is accepted even though managerName is missing, because it is optional. Leave out department and it is an error naming the missing property.
  • Adding a property the interface does not list is also an error when assigning an object literal directly like this. That catches typos such as departmnet, which in plain JavaScript would silently create a new property.
  • Using Employee as a parameter type means describe works with any object of that shape, from anywhere in the application.

interface and type both describe shapes, and they overlap heavily. The distinction is narrower than the amount written about it suggests:

 interfacetype alias
Object shapesDesigned for themEqually capable
UnionsCannot express oneThe only option: type Status = "draft" | "approved"
Naming other kinds of typeObjects onlyAnything — a function type, a tuple, a primitive alias
Extendingextends another interfaceIntersection with &
Declared twice with the same nameThe declarations merge, which is useful for adding to a library's types and surprising otherwiseAn error, which is usually what you want
A reasonable ruleObject shapes you might extend, including public API typesUnions, function types, and anything that is not an object
Unions, and narrowing them before use
TypeScript
type ExpenseStatus = "draft" | "submitted" | "approved" | "rejected";

interface Expense {
  readonly id: string;
  description: string;
  amount: number;
  status: ExpenseStatus;
  rejectionReason?: string;
}

function statusLabel(status: ExpenseStatus): string {
  switch (status) {
    case "draft":
      return "Not yet submitted";
    case "submitted":
      return "Awaiting approval";
    case "approved":
      return "Approved for payment";
    case "rejected":
      return "Rejected";
  }
}

function describeExpense(expense: Expense): string {
  if (expense.status === "rejected" && expense.rejectionReason) {
    return "Rejected: " + expense.rejectionReason;
  }

  return statusLabel(expense.status);
}
  • ExpenseStatus is a union of four exact strings. Assigning "aproved" is a compile error, which is a typo that otherwise produces a row matching no filter and no visible failure.
  • Inside the switch, each case narrows status to that one literal. The checker follows the control flow and knows which value you are dealing with in each branch.
  • There is no default, and it still satisfies the string return type, because the four cases cover every possible value. Add a fifth status to the union later and this function becomes an error — which is the behaviour you want, because it lists the places that need updating.
  • rejectionReason is optional, and the check for it is not defensive padding: without it the property is string | undefined and cannot be concatenated safely under strict mode.
  • Checking status === "rejected" first documents the pairing between the status and the reason. A discriminated union can enforce that pairing outright, which is worth reaching for once several fields depend on a status.
Typing functions you pass around
TypeScript
// A named function type, so callbacks are described in one place
type ExpenseFilter = (expense: Expense) => boolean;

const isOverLimit: ExpenseFilter = (expense) => expense.amount > 50;

function countMatching(expenses: Expense[], matches: ExpenseFilter): number {
  return expenses.filter(matches).length;
}

// Methods can be described inside an interface too
interface ExpenseStore {
  getAll(): Expense[];
  add(expense: Expense): void;
  onChange(handler: (expenses: Expense[]) => void): void;
}
  • A function type gives the parameters and, after the arrow, the return type. This one takes an Expense and answers true or false.
  • Because isOverLimit is annotated with that type, the parameter needs no annotation — the checker knows what it must be. This is contextual typing, and it is why callbacks passed to filter or map rarely need annotations.
  • countMatching accepts any function of that shape. The name ExpenseFilter makes the parameter self-describing, which an inline type does not.
  • Inside an interface, a method is written as a name with parameters and a return type. void means it returns nothing useful.
  • onChange takes a function as its argument, and its type is written inline. Nesting function types more than one level deep quickly becomes unreadable — name them at that point.

Habits that keep type definitions useful rather than decorative:

  • Define each concept once and import it, rather than re-describing it per file
  • Prefer a literal union over string for anything with a fixed set of values
  • Use ? only for properties that may genuinely be absent, not for ones that may be empty
  • Mark identifiers and created-at values readonly so accidental reassignment is a compile error
  • Name function types when they are passed around, and inline them when used once
  • Let the checker tell you what a change breaks — a switch over a union that no longer compiles is a to-do list

Summary

  • An interface writes down an object's shape once, and the checker verifies every use of it
  • Optional means may be absent, which is different from may be empty
  • readonly turns accidental reassignment of an id into a compile error
  • Literal unions replace string for fixed sets of values, and narrowing makes each branch specific
  • A discriminated union enforces relationships between fields that separate optional properties cannot

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Model a claim that can be rejected

Write the types for an expense claim where a rejected claim always has a reason and a rejecting approver, and a claim in any other status has neither.

Then write a function that renders a one-line summary, and see whether your types stop you reading the reason on an approved claim.

Show solution

Two optional properties would compile, and they would allow three states that cannot exist: rejected with no reason, approved with a reason, and rejected with an approver but no reason. Optional properties describe each field independently, and the requirement is a relationship between them.

A discriminated union expresses the relationship. Each member has the same status property with a different literal type, and checking that property narrows the whole object — so the reason is available in the rejected branch and does not exist in the others.

The pay-off is in the last line of the example: reading the reason outside the rejected branch is a compile error rather than undefined on screen. The type now enforces the rule instead of documenting it.

The cost is a slightly heavier definition, and it is worth it once more than one field depends on the status. For a single optional note, two optional properties are fine.

TypeScript
interface ClaimBase {
  readonly id: string;
  description: string;
  amount: number;
}

interface RejectedClaim extends ClaimBase {
  status: "rejected";
  rejectionReason: string;    // required in this member
  rejectedBy: string;
}

interface OpenClaim extends ClaimBase {
  status: "draft" | "submitted" | "approved";
}

type Claim = RejectedClaim | OpenClaim;

function summarise(claim: Claim): string {
  if (claim.status === "rejected") {
    // Both properties are known to exist here
    return claim.description + " — rejected by " + claim.rejectedBy + ": " + claim.rejectionReason;
  }

  return claim.description + " — " + claim.status;
  // claim.rejectionReason here is an error: it does not exist on OpenClaim
}

Think about it

Think about it

Why does typing a status as "draft" | "submitted" | "approved" | "rejected" catch more bugs than typing it as string, given that all four values are strings?

Show solution

string accepts every possible text, so the checker cannot distinguish a valid status from a typo. "Approved" and "aproved" both pass, and the failure is a comparison that is never true — no error, no crash, just a filter that finds nothing or a badge that never appears.

The union states the whole set, so a wrong value is rejected where it is written. It also enables narrowing, so each branch of a switch knows exactly which value it has.

The part people underrate: when a fifth status is added, every exhaustive switch over the union stops compiling. That is the checker handing you the list of places to update, which is far more reliable than searching for the word status.

Saved in this browser only.