Objects, Interfaces and Type Aliases
Describing the shape of objects: object types, interfaces and type aliases, optional and readonly properties, index signatures, and structural typing.
Object types
In JavaScript an object is a bag of named
properties, and nothing stops you from reading a property that is not there:
you just get undefined. TypeScript lets you write down
which properties an object has. An
object type looks like an object
literal, but with types instead of values, separated by semicolons
(commas work too):
const reading: { meterId: string; kWh: number } = {
meterId: "E-1001",
kWh: 4.2,
};
function describe(r: { meterId: string; kWh: number }): string {
return `${r.meterId}: ${r.kWh.toFixed(1)} kWh`;
}
console.log(describe(reading));
console.log(describe({ meterId: "E-1002", kWh: 12 }));
E-1001: 4.2 kWh E-1002: 12.0 kWh
With that information the compiler checks every object you create and every property you read. A missing property is an error, and so is a misspelled one. When the name is close to a real property, the message even suggests the right spelling:
const reading: { meterId: string; kWh: number } = { meterId: "E-1001" };
example.ts(1,7): error TS2741: Property 'kWh' is missing in type '{ meterId: string; }' but required in type '{ meterId: string; kWh: number; }'.
const reading = { meterId: "E-1001", kWh: 4.2 };
console.log(reading.kwh);
example.ts(2,21): error TS2551: Property 'kwh' does not exist on type '{ meterId: string; kWh: number; }'. Did you mean 'kWh'?
The second example has no annotation at all. TypeScript
infers
the object type from the literal, so even unannotated objects are
checked. In plain JavaScript, reading.kwh would quietly
be undefined and the mistake would surface later,
somewhere else, as NaN or a crash.
Interfaces
Writing the same object type out twice, as above, is tiring and easy to get out of sync. An interface gives an object type a name, so you can use that name everywhere the shape is needed:
interface Customer {
id: string;
name: string;
city: string;
tariff: string;
}
function label(c: Customer): string {
return `${c.name} (${c.id}), ${c.city}, ${c.tariff} tariff`;
}
const amara: Customer = {
id: "C-20417",
name: "Amara Okafor",
city: "Utrecht",
tariff: "dynamic",
};
console.log(label(amara));
Amara Okafor (C-20417), Utrecht, dynamic tariff
An interface can also describe methods, and it can
extends one or more other interfaces to add properties.
In TypeScript an interface usually describes data (an API
response, a configuration object, a row from a database), not only
the behavior of a class:
interface Meter {
id: string;
installed: string;
}
interface SmartMeter extends Meter {
intervalMinutes: number;
readKWh(): number;
}
const m: SmartMeter = {
id: "E-1001",
installed: "2024-03-12",
intervalMinutes: 15,
readKWh() {
return 4.2;
},
};
console.log(`${m.id} every ${m.intervalMinutes} min: ${m.readKWh()} kWh`);
E-1001 every 15 min: 4.2 kWh
A class can promise to match an interface with
implements; that is covered on
Classes. The general idea of an interface
as a contract is explained in
Object-Oriented Concepts.
Type aliases
A type alias gives a name to
any type with the type keyword: an object
type, but also a primitive, a union or a function type. The alias is
only a new name, not a new type: MeterId below is still
just string, and any string is accepted where it is
expected.
type MeterId = string;
type Euro = number;
type Tariff = {
code: string;
pricePerKWh: Euro;
};
type BillLine = {
meterId: MeterId;
kWh: number;
tariff: Tariff;
};
function lineTotal(line: BillLine): Euro {
return line.kWh * line.tariff.pricePerKWh;
}
const day: Tariff = { code: "DAY", pricePerKWh: 0.32 };
const line: BillLine = { meterId: "E-1001", kWh: 180, tariff: day };
console.log(`€${lineTotal(line).toFixed(2)}`);
€57.60
Aliases are where TypeScript's more expressive types live. Only
type can name a union such as
"day" | "night" (see
Union, Intersection and Literal Types). Function types are usually written with type too
(see Functions); an interface can
describe one only through a call signature,
interface PriceRule { (kWh: number): number }, which is
rarely used.
Interface or type?
For a plain object shape, the two are nearly interchangeable and the choice is mostly a team convention. The differences that matter in practice:
| Aspect | interface |
type |
|---|---|---|
| Describes | Object shapes (and classes, and functions via call signatures) | Any type: objects, unions, tuples, functions |
| Extending | interface B extends A { … } |
type B = A & { … } (an intersection) |
| Same name declared twice | The declarations are merged into one | Error TS2300: Duplicate identifier |
| Error messages | Show the interface name | Show the alias name, sometimes the expanded type |
The merging row deserves an example, because it can surprise you.
Two interface blocks with the same name in the same
scope silently become one interface with all the properties. That is
useful for extending types from a library (see
Modules, Declaration Files and Typing JavaScript Libraries), but in your own code it can also mean an accidental second
declaration adds a required property:
interface Ticket {
id: string;
}
interface Ticket {
priority: number;
}
const t: Ticket = { id: "INC-30412" };
example.ts(8,7): error TS2741: Property 'priority' is missing in type '{ id: string; }' but required in type 'Ticket'.
type Tariff = { code: string };
type Tariff = { pricePerKWh: number };
example.ts(1,6): error TS2300: Duplicate identifier 'Tariff'.
Optional and readonly properties
Optional properties
Real data has gaps: not every customer gave an email address. An
optional property is marked
with ? after its name. The object may leave it out, and
reading it gives the declared type or
undefined:
interface Customer {
id: string;
name: string;
email?: string;
phone?: string;
}
const customers: Customer[] = [
{ id: "C-20417", name: "Amara Okafor", email: "amara@example.com" },
{ id: "C-20418", name: "Joost Bakker" },
];
for (const c of customers) {
console.log(`${c.name}: ${c.email ?? "no email on file"}`);
}
Amara Okafor: amara@example.com Joost Bakker: no email on file
Because the value may be undefined, the compiler will
not let you use it as a string until you have handled that case.
Here the
nullish coalescing operator
?? supplies a fallback; forgetting it is an error:
interface Customer {
name: string;
email?: string;
}
function domain(c: Customer): string {
return c.email.split("@")[1];
}
example.ts(7,10): error TS18048: 'c.email' is possibly 'undefined'.
All the ways to deal with "possibly undefined" (checks, optional chaining, defaults) are on null, undefined and Strict Null Checks.
Readonly properties
readonly before a property name means it may be set
when the object is created, but not assigned afterward. It is the
same idea as a
readonly property
in PHP or C#:
interface Tariff {
readonly code: string;
pricePerKWh: number;
}
const night: Tariff = { code: "NIGHT", pricePerKWh: 0.25 };
night.pricePerKWh = 0.24;
night.code = "OFFPEAK";
example.ts(8,7): error TS2540: Cannot assign to 'code' because it is a read-only property.
Two limits are worth knowing. First, readonly is
shallow: the property cannot point to a different array,
but the array it points to can still change. For that, use the
readonly number[] array type, which has no
push, pop or other changing methods.
Second, all of this is checked only by the compiler. The emitted
JavaScript is an ordinary object; if you need protection at run
time, use
Object.freeze.
interface Reading {
readonly meterId: string;
readonly values: number[];
}
const r: Reading = { meterId: "E-1001", values: [4.2, 3.9] };
r.values.push(5.1);
console.log(r.values);
const frozen: readonly number[] = [4.2, 3.9];
console.log(frozen.length);
[ 4.2, 3.9, 5.1 ] 2
const frozen: readonly number[] = [4.2, 3.9];
frozen.push(5.1);
example.ts(2,8): error TS2339: Property 'push' does not exist on type 'readonly number[]'.
Structural typing
Here is the idea that makes TypeScript feel different from C# or Java. With structural typing, a value fits a type when it has the right shape: all required properties, with compatible types. Its name, its class and where it came from do not matter, and extra properties are fine. The alternative, used by C# and Java, is nominal typing, where a value only fits a type that it explicitly declares.
Reading is expected,
because both have meterId and kWh. The
extra properties (gray) are simply ignored.
interface Reading {
meterId: string;
kWh: number;
}
function describe(r: Reading): string {
return `${r.meterId}: ${r.kWh} kWh`;
}
const fromApi = { meterId: "E-1001", kWh: 4.2, city: "Utrecht", source: "smart" };
console.log(describe(fromApi));
class ManualReading {
meterId = "E-1002";
kWh = 12;
readBy = "Ravi Kumar";
}
console.log(describe(new ManualReading()));
E-1001: 4.2 kWh E-1002: 12 kWh
ManualReading never mentions Reading, and
fromApi has two properties the interface does not know.
Both are accepted. This matches how JavaScript code is actually
written: functions receive objects from JSON, from libraries and
from object literals, and what matters is which properties they
have. It is often called "duck typing, checked at compile time".
Excess property checks
Extra properties are allowed, as the previous section showed, with one deliberate exception. When you write an object literal directly where a type is expected, TypeScript performs an excess property check: a property that the target type does not have is reported. In a fresh literal, an unknown property is almost always a typo or a misunderstanding, and nobody else can ever read it:
interface Reading {
meterId: string;
kWh: number;
}
function describe(r: Reading): string {
return `${r.meterId}: ${r.kWh} kWh`;
}
console.log(describe({ meterId: "E-1001", kWh: 4.2, unit: "kWh" }));
example.ts(10,53): error TS2353: Object literal may only specify known properties, and 'unit' does not exist in type 'Reading'.
The check is most valuable for option objects, where every property is optional. Without it, a misspelled option would be silently ignored and the default used instead:
interface Options {
retries?: number;
timeoutMs?: number;
}
function connect(opts: Options): string {
return `retries=${opts.retries ?? 3}, timeout=${opts.timeoutMs ?? 5000}`;
}
console.log(connect({ retries: 5 }));
console.log(connect({ timeoutMS: 1000 }));
example.ts(11,23): error TS2561: Object literal may only specify known properties, but 'timeoutMS' does not exist in type 'Options'. Did you mean to write 'timeoutMs'?
The same object stored in a variable first is not fresh any
more, so the ordinary structural rule applies and the extra property
passes. The value really does have the unit property at
run time; the type Reading just does not mention it:
interface Reading {
meterId: string;
kWh: number;
}
const r = { meterId: "E-1001", kWh: 4.2, unit: "kWh" };
const checked: Reading = r;
console.log(checked);
{ meterId: 'E-1001', kWh: 4.2, unit: 'kWh' }
Index signatures
Sometimes the property names are data: a table of tariffs by code,
usage per meter ID, settings by name. An
index signature describes
such an object: [code: string]: number means "any
string key, and every value is a number". The name inside the
brackets (code) is only documentation.
interface TariffTable {
[code: string]: number;
}
const tariffs: TariffTable = { DAY: 0.32, NIGHT: 0.25 };
tariffs.WEEKEND = 0.27;
for (const [code, price] of Object.entries(tariffs)) {
console.log(`${code.padEnd(8)} €${price.toFixed(2)}`);
}
DAY €0.32 NIGHT €0.25 WEEKEND €0.27
The built-in shorthand
Record<string, number> means the same thing (see
Utility Types). Named properties
may sit next to an index signature, but their types must fit it:
with [id: string]: string, a property
updated: number is error TS2411.
The catch: an index signature promises that every key exists. By default, TypeScript believes that promise, so a typo in a key compiles and fails only when the code runs:
const tariffs: { [code: string]: number } = { DAY: 0.32, NIGHT: 0.25 };
console.log(tariffs.DAY.toFixed(2));
console.log(tariffs.DYA.toFixed(2));
0.32 TypeError: Cannot read properties of undefined (reading 'toFixed')
Two remedies. If the keys are known in advance, do not use an index
signature at all: an object type with the exact keys (or an inferred
one) turns tariffs.DYA into a compile error. If the
keys really are open-ended, switch on the compiler option
noUncheckedIndexedAccess, which makes every lookup
number | undefined so you must handle the missing case;
it is explained on
null, undefined and Strict Null Checks. For lookups that change at run time, a
Map
is often the better tool, because its get method
already returns undefined for missing keys.
The opposite problem appears when an object has fixed keys and you index it with an arbitrary string. TypeScript refuses, because the string might be a key that does not exist:
const tariffs = { DAY: 0.32, NIGHT: 0.25 };
function price(code: string): number {
return tariffs[code];
}
example.ts(4,10): error TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ DAY: number; NIGHT: number; }'.
The fix is to narrow the parameter to the real keys,
code: "DAY" | "NIGHT", or more generally
keyof typeof tariffs (see
Type Operators: keyof, typeof and Indexed Access), or to check the string before using it.
Putting it together
A small model of a Kestrel Energy bill uses most of the page: an interface with optional and readonly properties, an alias for nested data, an index signature for the tariff table, and a function that relies on the compiler to catch shape mistakes.
interface Customer {
readonly id: string;
name: string;
email?: string;
}
type UsageLine = {
meterId: string;
tariff: string;
kWh: number;
};
interface Bill {
customer: Customer;
lines: UsageLine[];
}
const prices: Record<string, number> = { DAY: 0.32, NIGHT: 0.25 };
const VAT = 0.21;
function total(bill: Bill): number {
let sum = 0;
for (const line of bill.lines) {
const price = prices[line.tariff];
if (price === undefined) {
throw new Error(`Unknown tariff ${line.tariff} on ${line.meterId}`);
}
sum += line.kWh * price;
}
return sum * (1 + VAT);
}
const bill: Bill = {
customer: { id: "C-20417", name: "Amara Okafor" },
lines: [
{ meterId: "E-1001", tariff: "DAY", kWh: 180 },
{ meterId: "E-1001", tariff: "NIGHT", kWh: 95 },
],
};
console.log(`${bill.customer.name}: €${total(bill).toFixed(2)} incl. VAT`);
console.log(`Send to: ${bill.customer.email ?? "(post)"}`);
Amara Okafor: €98.43 incl. VAT Send to: (post)
Note the explicit price === undefined check: the index
signature claims every tariff code exists, so the code checks
anyway. That is the habit to build for any data that comes from
outside your program. Values from JSON, an API or a user are
unknown until they have been validated, and a type
annotation is a description, not a check (see
Async Code and Typing API Data).