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.

Structural typing. The interface Reading on the left requires meterId of type string and kWh of type number. Two values on the right fit it: an object literal from an API with the extra properties city and source, and an instance of a class ManualReading with the extra property readBy. Both have the two required properties, so both are accepted. Extra properties are ignored. interface Reading meterId: string kWh: number fromApi (object literal) meterId, kWh city, source new ManualReading(…) meterId, kWh readBy fits: has both fits: has both
Both values are accepted where a 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).