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.
readonlyfields block assignment at compile time; optional fields carry the possibility ofundefinedinto the type.- The
extendssyntax 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.