Skip to content
aviral gupta

// B5.1 · ~30 min · Beginner

Optional and readonly properties

After this lesson you can give optional properties defaults where you destructure them, mark properties readonly, and say what readonly protects and what it does not.

Lesson 1 of 5 in B5 Object types

Start of the module

You will be able to

  • Give optional properties defaults in a destructuring pattern, and tell a missing property from one set to undefined
  • Mark properties readonly, read error TS2540, and explain why readonly is shallow
  • Explain why a readonly property can still change through another reference, and at run time
  1. Warm-up · Activity 1 of 7

    Warm-up from JavaScript destructuring: which defaults are used? What does this print?

    const { a = 1, b = 2, c = 3 } = { a: undefined, b: null };
    console.log(a, b, c);
  2. Predict · Activity 2 of 7

    Predict before you read on: resident is readonly. Which line does tsc reject?

    interface Home {
      readonly resident: { name: string; age: number };
    }
    
    function visitForBirthday(home: Home) {
      home.resident.age++;
    }
    
    function evict(home: Home) {
      home.resident = { name: "Victor the Evictor", age: 42 };
    }
  3. Practice · Activity 3 of 7

    Fill in the gap so that xPos defaults to 0, as yPos does, and the program prints circle at 0,0, then square at 100,0.

    interface PaintOptions {
      shape: string;
      xPos?: number;
      yPos?: number;
    }
    
    function paintShape({ shape, xPos ____, yPos = 0 }: PaintOptions) {
      console.log(shape + " at " + xPos + "," + yPos);
    }
    
    paintShape({ shape: "circle" });
    paintShape({ shape: "square", xPos: 100 });
    function paintShape({ shape, xPos , yPos = 0 }: PaintOptions) {
  4. Practice · Activity 4 of 7

    Both properties are optional, but only fontSize gets a default. Which types do theme and fontSize have at the comment?

    interface Settings {
      theme?: string;
      fontSize?: number;
    }
    
    function describe(settings: Settings) {
      const { theme, fontSize = 14 } = settings;
      // here
    }
  5. Practice · Activity 5 of 7

    A project turns on exactOptionalPropertyTypes in its tsconfig.json (the course tsconfig does not, and there this file has no errors). Which line does tsc reject then?

    interface UserDefaults {
      colorThemeOverride?: "dark" | "light";
    }
    
    const settings: UserDefaults = {};
    settings.colorThemeOverride = "dark";
    settings.colorThemeOverride = undefined;
    delete settings.colorThemeOverride;
  6. Brain teaser · Activity 6 of 7

    Brain teaser, the Handbook's example. What happens when you run npx tsc, then node main.ts?

    interface Person {
      name: string;
      age: number;
    }
    
    interface ReadonlyPerson {
      readonly name: string;
      readonly age: number;
    }
    
    let writablePerson: Person = { name: "Person McPersonface", age: 42 };
    let readonlyPerson: ReadonlyPerson = writablePerson;
    
    console.log(readonlyPerson.age);
    writablePerson.age++;
    console.log(readonlyPerson.age);
  7. Apply · Activity 7 of 7

    Mini-task. Write an interface ShopConfig with a readonly shopName (a string), and two optional properties: currency (a string) and taxPercent (a number). Write describeShop, which destructures its parameter with the defaults EUR and 19 and returns text such as Corner Shop (EUR, tax 19%). Call it once with only a shopName and once with taxPercent: 7. Then try config.shopName = "Other" and read the error. Check it in the editor, or with npx tsc and node main.ts.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

An order with defaults and readonly properties

describeOrder destructures its parameter and gives quantity and giftWrap defaults, so the body needs no undefined checks. id and customer are readonly. The program then changes the customer's city, which readonly allows, because it is only one level deep. Run npx tsc, then node main.ts. Then add order.id = "X"; or order.customer = order.customer; and run tsc again.

main.ts

// An order: optional settings get defaults, the id and the customer are readonly.
interface Customer {
  name: string;
  city: string;
}

interface Order {
  readonly id: string;
  readonly customer: Customer;
  quantity?: number;
  giftWrap?: boolean;
}

// Defaults in the pattern: in the body, quantity is a number and giftWrap a boolean.
function describeOrder({ id, customer, quantity = 1, giftWrap = false }: Order): string {
  const wrap = giftWrap ? ", gift-wrapped" : "";
  return id + ": " + quantity + " for " + customer.name + " in " + customer.city + wrap;
}

const order: Order = { id: "A-17", customer: { name: "Ada", city: "Bonn" } };
console.log(describeOrder(order));

// readonly is shallow: order.customer cannot point elsewhere, but its city can change.
order.customer.city = "Berlin";
console.log(describeOrder({ ...order, quantity: 3, giftWrap: true }));

// An explicit undefined gets the default too.
console.log(describeOrder({ id: "B-2", customer: order.customer, quantity: undefined }));

Run it with

npx tsc
node main.ts

Output

A-17: 1 for Ada in Bonn
A-17: 3 for Ada in Berlin, gift-wrapped
B-2: 1 for Ada in Berlin
  • Callers see quantity and giftWrap as optional; inside describeOrder they are a number and a boolean.
  • order.id = "X" would be error TS2540: Cannot assign to 'id' because it is a read-only property.
  • order.customer.city = "Berlin" compiles: customer is readonly, city is not.
  • quantity: undefined gets the default 1, just as a missing quantity does.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the TypeScript compiler (up to 2.1 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 2

Defaults instead of undefined checks

formatPrice should give 4.50 EUR for { amount: 4.5 }: currency defaults to eur, written in capitals, and decimals to 2. tsc reports error TS18048: 'options.currency' is possibly 'undefined'. Destructure the parameter instead, and give currency and decimals their defaults in the pattern. A decimals of 0 must stay 0. Run the tests: the first one is the type-check.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the TypeScript compiler (up to 2.1 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Replace the parameter options: PriceOptions with a pattern: ({ amount, currency, decimals }: PriceOptions).

  2. Hint 2

    A default goes after the name inside the pattern, with =: currency = "eur".

  3. Hint 3

    Use the variables in the body: amount.toFixed(decimals) + " " + currency.toUpperCase().

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export interface PriceOptions {
  amount: number;
  currency?: string;
  decimals?: number;
}

export function formatPrice({ amount, currency = "eur", decimals = 2 }: PriceOptions): string {
  return amount.toFixed(decimals) + " " + currency.toUpperCase();
}
Run it on your computer

Install TypeScript 7.0 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.ts

export interface PriceOptions {
  amount: number;
  currency?: string;
  decimals?: number;
}

export function formatPrice(options: PriceOptions): string {
  return options.amount.toFixed(options.decimals) + " " + options.currency.toUpperCase();
}

main.test.ts

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {formatPrice} from './main.ts';

test('both defaults are used', () => {
  const got = formatPrice({amount: 4.5});
  assert.equal(got, '4.50 EUR', `formatPrice({amount: 4.5}) gave ${JSON.stringify(got)}`);
});

test('a given currency and decimals are used', () => {
  const got = formatPrice({amount: 3, currency: 'usd', decimals: 1});
  assert.equal(got, '3.0 USD', `formatPrice({amount: 3, currency: 'usd', decimals: 1}) gave ${JSON.stringify(got)}`);
});

test('decimals: 0 stays 0', () => {
  const got = formatPrice({amount: 7, decimals: 0});
  assert.equal(got, '7 EUR', `formatPrice({amount: 7, decimals: 0}) gave ${JSON.stringify(got)}`);
});

package.json

{
  "type": "module"
}

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "lib": [
      "esnext",
      "dom"
    ],
    "types": [],
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true
  },
  "include": [
    "**/*.ts"
  ],
  "exclude": [
    "**/*.test.ts"
  ]
}

npx tsc needs TypeScript in the folder: run npm install --save-dev typescript there once. tsc checks the types; Node.js runs main.ts by removing them.

Run the program:

npx tsc
node main.ts

Run the checks (needs learnrun.js in the same folder):

npx tsc
node --test
Download learnrun.js

Exercise 2 of 2

Readonly ids, and a customer who moves

An order must keep its id and its customer object; other parts of the shop hold the same customer object and must see a new city. forbiddenChanges proves the first part: each // @ts-expect-error expects an error on the next line, and tsc reports error TS2578: Unused '@ts-expect-error' directive. while the writes still compile. Mark id and customer readonly. Then fix moveCustomer, which tsc now rejects: change the city of the customer object the order already has.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the TypeScript compiler (up to 2.1 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    readonly goes before the property name in the interface: readonly id: string;

  2. Hint 2

    After that, order.customer = { … } in moveCustomer is error TS2540. readonly is shallow: the city inside customer can still change.

  3. Hint 3

    The body of moveCustomer becomes order.customer.city = city;

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export interface Order {
  readonly id: string;
  readonly customer: { name: string; city: string };
  total: number;
}

export function moveCustomer(order: Order, city: string): void {
  order.customer.city = city;
}

export function addToTotal(order: Order, amount: number): void {
  order.total += amount;
}

function forbiddenChanges(order: Order) {
  // @ts-expect-error: an order keeps its id
  order.id = "X-0";
  // @ts-expect-error: an order keeps its customer object
  order.customer = { name: "Mallory", city: "Nowhere" };
}
Run it on your computer

Install TypeScript 7.0 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.ts

export interface Order {
  id: string;
  customer: { name: string; city: string };
  total: number;
}

export function moveCustomer(order: Order, city: string): void {
  order.customer = { name: order.customer.name, city };
}

export function addToTotal(order: Order, amount: number): void {
  order.total += amount;
}

function forbiddenChanges(order: Order) {
  // @ts-expect-error: an order keeps its id
  order.id = "X-0";
  // @ts-expect-error: an order keeps its customer object
  order.customer = { name: "Mallory", city: "Nowhere" };
}

main.test.ts

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {moveCustomer, addToTotal} from './main.ts';

test('moveCustomer sets the new city', () => {
  const order = {id: 'A-1', customer: {name: 'Ada', city: 'Bonn'}, total: 10};
  moveCustomer(order, 'Berlin');
  assert.equal(order.customer.city, 'Berlin', `the city is ${order.customer.city}`);
});

test('moveCustomer keeps the same customer object', () => {
  const customer = {name: 'Ada', city: 'Bonn'};
  const order = {id: 'A-1', customer, total: 10};
  moveCustomer(order, 'Berlin');
  assert.ok(order.customer === customer, 'order.customer was replaced by a new object');
  assert.equal(customer.city, 'Berlin', `the shared customer object still says ${customer.city}`);
});

test('addToTotal adds to the total', () => {
  const order = {id: 'A-1', customer: {name: 'Ada', city: 'Bonn'}, total: 10};
  addToTotal(order, 2.5);
  assert.equal(order.total, 12.5, `the total is ${order.total}`);
});

package.json

{
  "type": "module"
}

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "lib": [
      "esnext",
      "dom"
    ],
    "types": [],
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true
  },
  "include": [
    "**/*.ts"
  ],
  "exclude": [
    "**/*.test.ts"
  ]
}

npx tsc needs TypeScript in the folder: run npm install --save-dev typescript there once. tsc checks the types; Node.js runs main.ts by removing them.

Run the program:

npx tsc
node main.ts

Run the checks (needs learnrun.js in the same folder):

npx tsc
node --test
Download learnrun.js

Common mistakes

Writing to a readonly property

interface Config {
  readonly apiUrl: string;
  retries: number;
}

const config: Config = { apiUrl: "https://example.com", retries: 3 };
config.retries = 5;
config.apiUrl = "http://localhost";

What tsc or Node.js prints

main.ts(8,8): error TS2540: Cannot assign to 'apiUrl' because it is a read-only property.

Why, and the fix

The type says apiUrl is set once, when the object is created, and never changed. If another URL is needed, create a new object: const local: Config = { ...config, apiUrl: "http://localhost" }. If it really has to change in place, the property should not be readonly: remove the modifier rather than working around it.

A destructured optional property without a default

interface Settings {
  theme?: string;
  fontSize?: number;
}

function describe(settings: Settings) {
  const { theme, fontSize = 14 } = settings;
  return theme.toUpperCase() + " " + fontSize + "px";
}

What tsc or Node.js prints

main.ts(8,10): error TS18048: 'theme' is possibly 'undefined'.

Why, and the fix

Destructuring does not remove undefined: theme is string | undefined, as settings.theme would be. fontSize is a number only because it has a default. Give theme one too, const { theme = "light", fontSize = 14 } = settings, or check it for undefined before calling toUpperCase.

Calling with no argument when only the properties have defaults

interface Options {
  host?: string;
  port?: number;
}

function connect({ host = "localhost", port = 80 }: Options) {
  return host + ":" + port;
}

console.log(connect());

What tsc or Node.js prints

main.ts(10,13): error TS2554: Expected 1 arguments, but got 0.

Why, and the fix

The defaults belong to the properties of the object, but the object itself is still a required parameter, and destructuring undefined would throw a TypeError. Give the whole parameter a default as well: function connect({ host = "localhost", port = 80 }: Options = {}). Then connect() uses an empty object, and both property defaults apply.

TypeScript in the browser: the TypeScript 6.0.3 compiler, Apache-2.0, then your browser’s own engine. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Defaults for optional properties, in the pattern

B2.3 checked an optional property for undefined before using it. A default in a destructuring pattern does that for you: function paintShape({ shape, xPos = 0, yPos = 0 }: PaintOptions). Callers may still leave xPos out, but in the body it is a number. Without a default, const { theme } = settings gives string | undefined. A default replaces only undefined, so quantity: 0 stays 0. For a call with no argument, default the whole parameter too: ({ port = 80 }: Options = {}); otherwise connect() is error TS2554. theme?: string also accepts theme: undefined, although "theme" in settings is then true. The option exactOptionalPropertyTypes, which strict does not turn on, rejects that with error TS2375.

readonly forbids writing a property, one level deep

readonly before a property name forbids assigning to it: interface Config { readonly apiUrl: string; retries: number }. Reading is fine, and so is giving it a value when the object is created, but config.apiUrl = "…" is error TS2540: Cannot assign to 'apiUrl' because it is a read-only property. delete config.apiUrl is error TS2704. This is not const: const fixes a variable, readonly a property, and const p = { x: 1 } still allows p.x = 2. readonly is shallow. With readonly resident: { name: string; age: number }, home.resident = { … } is TS2540, but home.resident.age++ compiles: the property cannot point to another object, while that object can still change.

readonly belongs to the type, not to the object

The Handbook: "TypeScript doesn’t factor in whether properties on two types are readonly when checking whether those types are compatible". So let readonlyPerson: ReadonlyPerson = writablePerson compiles, and after writablePerson.age++, readonlyPerson.age prints 43 instead of 42: two names, one object. The other way round compiles too: a function that takes a Person may change the ReadonlyPerson you pass it. And nothing of readonly is left at run time. Node.js strips it, so a write that tsc rejected still happens when you run the file anyway. Read readonly as a promise about one name: code that uses this name does not change the property. Whoever holds a mutable reference still can.

Sources

Last reviewed October 4, 2026