Skip to content
academia.sh

Lesson 07 / 23

Interfaces

Defining a structural contract with an interface, optional and readonly fields, extension, index signatures, and declaration merging.

Contents

The previous topic established the types of individual values. This topic moves on to naming and sharing those types: how is the contract a module exposes written, and how are different implementations that satisfy the same contract defined?

An interface is a declaration that names the fields and methods a value has to carry. It is the direct counterpart of the abstract data type idea from the Data Structures course: a stack is defined not by its internal representation but by its push, pop, peek, and is_empty operations. An interface likewise defines a type by the operations it offers, not by its internal structure.

Interface Declaration

The measurement record’s model can be written as an interface:

type Unit = "C" | "Pa" | "%";

interface Measurement {
  readonly id: string;
  sensor: string;
  value: number;
  unit: Unit;
  time: number;
  note?: string;
}

const record: Measurement = {
  id: "s-01",
  sensor: "temperature",
  value: 21.4,
  unit: "C",
  time: 1706000000000,
};

console.log(record.note);
record.value = 22.1;
record.id = "s-02";
g1.ts(22,8): error TS2540: Cannot assign to 'id' because it is a read-only property.

Two new markers appear in the declaration.

The readonly id field blocks assignment at compile time: the value assignment on line 21 is valid, the one on line 22 is not. The boundary from the previous lesson holds here too — readonly is erased at runtime and does not protect the object itself.

The note?: string field is declared optional. Assignment is accepted even though it is missing from the object literal; accessing record.note has the type string | undefined and has to be narrowed before it can be used directly. Line 20 only prints it, so nothing goes wrong there — its output is undefined.

An optional field carries the “this information may not be present” case up to the type level. The alternative — making the field required and putting an empty string there — loses information: an empty string could mean “not measured” or “the note was empty.”

Extension

An interface can inherit all of another interface’s requirements:

interface Measurement {
  id: string;
  value: number;
}

interface LocatedMeasurement extends Measurement {
  lat: number;
  lon: number;
}

const record: LocatedMeasurement = {
  id: "s-01",
  value: 21.4,
  lat: 41.0,
  lon: 29.0,
};
console.log(record.id, record.lat);

const missing: LocatedMeasurement = { id: "s-02", value: 22.1 };
g2.ts(19,7): error TS2739: Type '{ id: string; value: number; }' is missing the following properties from type 'LocatedMeasurement': lat, lon

The extends syntax produces the same result as an intersection type: the LocatedMeasurement type requires both its own fields and Measurement’s fields. There are two differences.

First, extends checks compatibility at declaration time:

interface Base {
  unit: string;
}

interface Derived extends Base {
  unit: number;
}

declare const x: Derived;
console.log(x.unit);
g6.ts(5,11): error TS2430: Interface 'Derived' incorrectly extends interface 'Base'.
  Types of property 'unit' are incompatible.
    Type 'number' is not assignable to type 'string'.

The diagnostic points to the declaration where the conflict lives. The same conflict written with an intersection type would silently collapse to never, as seen in the previous lesson, with the error surfacing only at the point of use.

Second, the extends chain appears by name in diagnostic messages and improves readability.

This inheritance is separate from the class inheritance in the Programming Fundamentals course: what is inherited here is not behavior, only the contract. An interface contains no implementation.

Declaration Merging

Interfaces have a property type aliases lack: they can be declared more than once under the same name. The declarations get merged.

interface Measurement {
  id: string;
}

interface Measurement {
  value: number;
}

const record: Measurement = { id: "s-01", value: 21.4 };
console.log(record.id, record.value);

const missing: Measurement = { id: "s-02" };
g3.ts(12,7): error TS2741: Property 'value' is missing in type '{ id: string; }' but required in type 'Measurement'.

The two declarations formed a single interface; the twelfth line being rejected proves it.

This looks like a convenience, but its real purpose is different: it enables extending an existing type from the outside. A field can be added to an interface a library defines without modifying that library. The Declaration Files lesson shows this capability’s real use case.

The same capability also carries a risk: an interface’s full definition may not be visible in a single file. Relying on declaration merging in your own code makes the definition harder to trace.

Index Signatures

For cases where field names are not known in advance, an index signature is written:

interface TaggedMeasurement {
  id: string;
  value: number;
  [tag: string]: string | number;
}

const record: TaggedMeasurement = {
  id: "s-01",
  value: 21.4,
  location: "boiler-2",
  shift: 3,
};

console.log(record.location, record["shift"]);
console.log(record.missingTag);

This file passes type checking. Its output:

boiler-2 3
undefined

Two rules are at work. First, with an index signature present, the named fields’ types have to fit the signature’s type; id: string and value: number are accepted because they fall inside the string | number union. A boolean field would trigger a diagnostic.

Second, and more importantly: an index signature does not treat access to a nonexistent key as an error. The record.missingTag expression on the last line has type string | number as far as the compiler is concerned, and undefined at runtime. Here the type system makes a promise beyond reality.

A compiler option closes this gap — a strictness flag that adds undefined to the result of indexed access — covered in the Compiler Configuration lesson. General rule: if the key set is known, writing the fields out individually or using a mapped type is safer than an index signature.

Interface as a Contract

An interface’s real power shows up less in defining a data shape and more in establishing a behavioral contract. The next step for the measurement record model is to abstract away where records come from:

type Measurement = { id: string; value: number };

interface MeasurementSource {
  readonly name: string;
  next(): Measurement | null;
}

function arraySource(name: string, records: readonly Measurement[]): MeasurementSource {
  let index = 0;
  return {
    name,
    next() {
      return index < records.length ? records[index++] : null;
    },
  };
}

function constantSource(name: string, value: number): MeasurementSource {
  return {
    name,
    next() {
      return { id: name, value };
    },
  };
}

function firstTwo(source: MeasurementSource): string {
  const a = source.next();
  const b = source.next();
  return `${source.name}: ${a?.value ?? "-"} ${b?.value ?? "-"}`;
}

console.log(firstTwo(arraySource("file", [{ id: "s-01", value: 21.4 }])));
console.log(firstTwo(constantSource("constant", 20)));

Output:

file: 21.4 -
constant: 20 20

firstTwo knows neither source; it knows only the MeasurementSource contract. This is the type-level counterpart of the interface–implementation distinction from the Programming Fundamentals course: the contract and the code satisfying it can change independently.

Worth noticing: no implementation writes the name MeasurementSource. The object literal arraySource returns carries no declaration saying “this is a measurement source”; it is accepted only because it has the required fields. This is the foundation of TypeScript’s type compatibility, spelled out in the Structural Type Compatibility lesson.

The return type being Measurement | null is also a design decision. The a?.value and ?? "-" syntax inside firstTwo has to handle that union; the compiler does not allow the null possibility to be skipped.

Summary

  • An interface names the fields and methods a value has to carry; it is the type-level counterpart of the abstract data type idea.
  • readonly fields block assignment at compile time; optional fields carry the possibility of undefined into the type.
  • The extends syntax merges contracts and checks conflicts at declaration time; it does not pass on implementation.
  • Interface declarations under the same name get merged; this capability is for extending external types, and it makes tracing a definition harder in your own code.
  • An index signature types unknown keys but does not treat access to a nonexistent key as an error; if the key set is known, the fields are written out individually.

Next Step

The same measurement model could also have been written with the type keyword instead of interface — and indeed, that is how it was written in the previous topic. The two declaration forms are interchangeable in most cases, but they are not exactly equivalent. The next lesson will list the differences and build a criterion for which one to choose in which situation.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close