TypeScriptReadonly Members & Parameter Properties

Readonly Members & Parameter Properties

TypeScript provides two closely related features for working with class properties: the readonly modifier and parameter properties. Together, they let you write safer, more concise class definitions where immutability is enforced at compile time.

The readonly Modifier

Marking a class property readonly means it can only be assigned during initialization — either at the declaration site or inside the constructor. Any attempt to reassign it afterwards is a compile error.

TS
class Point {
  readonly x: number;
  readonly y: number;

  constructor(x: number, y: number) {
    this.x = x; // ✅ assignment in constructor — allowed
    this.y = y;
  }

  translate(dx: number, dy: number): Point {
    // ❌ Error: Cannot assign to 'x' because it is a read-only property.
    this.x += dx;
    this.y += dy;

    // ✅ Correct: return a new instance
    return new Point(this.x + dx, this.y + dy);
  }
}

const p = new Point(3, 4);
p.x = 10; // ❌ Error: Cannot assign to 'x' because it is a read-only property.
Note
readonly is a compile-time check only — at runtime the property is a regular writable property unless you also use Object.freeze() or a Proxy.
readonly with Initializers

Readonly properties can also be initialized at the point of declaration. Once set, they cannot be changed — not even in the constructor.

TS
class AppConfig {
  readonly MAX_RETRIES = 3;
  readonly BASE_URL = 'https://api.example.com';
  readonly VERSION: string;

  constructor(version: string) {
    this.VERSION = version; // ✅ constructor assignment allowed
  }

  updateUrl() {
    // ❌ Error: Cannot assign to 'BASE_URL' because it is a read-only property.
    this.BASE_URL = 'https://other.example.com';
  }
}

const config = new AppConfig('2.0.0');
console.log(config.VERSION);    // '2.0.0'
console.log(config.MAX_RETRIES); // 3
Parameter Properties

A parameter property is a TypeScript shorthand that simultaneously declares a class property and assigns the constructor argument to it. You add an access modifier (public, private, protected, or readonly) to a constructor parameter.

TS
// Without parameter properties — verbose
class User {
  public readonly id: string;
  public name: string;
  private email: string;
  protected role: string;

  constructor(id: string, name: string, email: string, role: string) {
    this.id = id;
    this.name = name;
    this.email = email;
    this.role = role;
  }
}

// With parameter properties — concise, identical runtime behavior
class User {
  constructor(
    public readonly id: string,
    public name: string,
    private email: string,
    protected role: string,
  ) {}
  // No body needed — TypeScript generates all four assignments
}

const user = new User('u1', 'Alice', 'alice@example.com', 'admin');
console.log(user.id);   // 'u1'
console.log(user.name); // 'Alice'
Tip
Parameter properties are one of TypeScript's most popular quality-of-life features. They reduce the boilerplate of value-object classes to a single constructor line.
Mixing Parameter Properties with Regular Properties

You can mix parameter properties with regular properties in the same class. Parameter properties appear in the constructor signature; derived or computed properties are declared in the class body.

TS
class Product {
  readonly sku: string;
  discountedPrice: number;

  constructor(
    public readonly id: string,
    public name: string,
    public price: number,
    private readonly taxRate: number = 0.2,
  ) {
    // Computed from parameter properties
    this.sku = `${id}-${name.slice(0, 3).toUpperCase()}`;
    this.discountedPrice = price * 0.9; // 10% off
  }

  get priceWithTax(): number {
    return this.price * (1 + this.taxRate);
  }

  applyDiscount(percent: number): void {
    this.discountedPrice = this.price * (1 - percent / 100);
    // Note: this.price is public (mutable), this.taxRate is readonly (immutable)
  }
}

const p = new Product('001', 'Widget', 29.99);
console.log(p.sku);             // '001-WID'
console.log(p.priceWithTax);    // 35.988
console.log(p.discountedPrice); // 26.991
readonly Arrays and Objects

readonly on a property only prevents reassigning the property reference. If the property holds an array or object, the contents of that array/object can still be mutated. To prevent deep mutation, use ReadonlyArray<T> or Readonly<T>.

TS
class Team {
  readonly members: string[];  // reference is readonly, but array is mutable!

  constructor(members: string[]) {
    this.members = members;
  }

  addMember(name: string) {
    this.members.push(name); // ✅ Compiles! The array is mutable.
    // this.members = [...this.members, name]; // ❌ Cannot assign to readonly
  }
}

// To make the array itself immutable, use ReadonlyArray<T>:
class ImmutableTeam {
  readonly members: ReadonlyArray<string>;

  constructor(members: string[]) {
    this.members = members;
  }

  addMember(name: string): ImmutableTeam {
    // ❌ Error: Property 'push' does not exist on type 'readonly string[]'
    // this.members.push(name);

    // ✅ Return a new instance
    return new ImmutableTeam([...this.members, name]);
  }
}

// For objects, use Readonly<T>:
class Config {
  readonly options: Readonly<{ timeout: number; retries: number }>;

  constructor(timeout: number, retries: number) {
    this.options = { timeout, retries };
  }

  withTimeout(ms: number): Config {
    // ❌ Error: Cannot assign to 'timeout' because it is a read-only property
    // this.options.timeout = ms;

    // ✅ Return new instance
    return new Config(ms, this.options.retries);
  }
}
Warning
readonly does NOT recursively make nested objects immutable. Use ReadonlyArray, Readonly<T>, or deep-freeze utilities for full immutability.
readonly in Interfaces

The readonly modifier works in interfaces and type aliases too, providing immutability guarantees at the type level regardless of how the implementing class declares the property.

TS
interface Vector2D {
  readonly x: number;
  readonly y: number;
  magnitude(): number;
}

class Vec implements Vector2D {
  constructor(
    public readonly x: number,
    public readonly y: number,
  ) {}

  magnitude(): number {
    return Math.sqrt(this.x ** 2 + this.y ** 2);
  }

  add(other: Vector2D): Vec {
    return new Vec(this.x + other.x, this.y + other.y);
  }
}

// Even through the interface, readonly is enforced:
const v: Vector2D = new Vec(3, 4);
v.x = 10; // ❌ Error: Cannot assign to 'x' because it is a read-only property.
Readonly Utility Type

TypeScript's built-in Readonly<T> utility type wraps all properties of a type in readonly, creating a deeply-immutable view of an object type. Note: this is shallow — nested objects are not made readonly.

TS
interface UserSettings {
  theme: 'light' | 'dark';
  language: string;
  notifications: boolean;
}

// A frozen snapshot of settings
type FrozenSettings = Readonly<UserSettings>;

// Equivalent to:
// {
//   readonly theme: 'light' | 'dark';
//   readonly language: string;
//   readonly notifications: boolean;
// }

function applySettings(settings: Readonly<UserSettings>) {
  // Caller's settings cannot be mutated inside this function
  settings.theme = 'dark'; // ❌ Error: Cannot assign to 'theme'

  // Must return a new object
  return { ...settings, theme: 'dark' } as UserSettings;
}

// Deep readonly — a recursive utility type
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
as const vs readonly

as const and readonly are related but different:

  • readonly is a modifier on a class/interface property declaration
  • as const is an assertion on a value that narrows the literal type and makes it readonly

TS
// as const — makes a value deeply readonly at the type level
const DIRECTIONS = ['up', 'down', 'left', 'right'] as const;
// type: readonly ['up', 'down', 'left', 'right']
// DIRECTIONS[0] is 'up', not string

type Direction = typeof DIRECTIONS[number]; // 'up' | 'down' | 'left' | 'right'

DIRECTIONS.push('diagonal'); // ❌ Error: Property 'push' does not exist on readonly array

// as const on objects
const DEFAULT_CONFIG = {
  timeout: 5000,
  retries: 3,
  baseUrl: 'https://api.example.com',
} as const;
// All properties are readonly AND narrowed to literal types

DEFAULT_CONFIG.timeout = 1000; // ❌ Error: Cannot assign to 'timeout'
// DEFAULT_CONFIG.timeout type is 5000, not number
Real-World Pattern: Immutable Value Objects

Immutable value objects — objects whose identity is defined by their value, not their reference — are a cornerstone of domain-driven design. TypeScript's readonly and parameter properties make them easy to implement.

TS
class Money {
  constructor(
    public readonly amount: number,
    public readonly currency: string,
  ) {
    if (amount < 0) throw new Error('Amount cannot be negative');
    if (!currency) throw new Error('Currency is required');
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error(`Cannot add ${this.currency} and ${other.currency}`);
    }
    return new Money(this.amount + other.amount, this.currency);
  }

  multiply(factor: number): Money {
    return new Money(this.amount * factor, this.currency);
  }

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }

  toString(): string {
    return `${this.currency} ${this.amount.toFixed(2)}`;
  }
}

const price = new Money(29.99, 'USD');
const tax   = new Money(2.99, 'USD');
const total = price.add(tax);

console.log(total.toString()); // USD 32.98
console.log(price.equals(total)); // false

price.amount = 0; // ❌ Error — immutable!
Real-World Pattern: Dependency Injection

Parameter properties are the standard pattern in NestJS, Angular, and other dependency-injection frameworks.

TS
// NestJS service with parameter properties
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
    private readonly emailService: EmailService,
    private readonly logger: Logger,
  ) {}
  // All three are automatically declared as private readonly class properties

  async create(dto: CreateUserDto): Promise<User> {
    const user = this.userRepository.create(dto);
    await this.userRepository.save(user);
    await this.emailService.sendWelcome(user.email);
    this.logger.log(`User created: ${user.id}`);
    return user;
  }
}

// Angular component with parameter properties
import { Component } from '@angular/core';

@Component({ selector: 'app-root', template: '...' })
export class AppComponent {
  constructor(
    private readonly authService: AuthService,
    private readonly router: Router,
  ) {}
}
Success
NestJS and Angular both rely heavily on parameter properties — the entire dependency injection pattern is built around this TypeScript shorthand. Every service, controller, and component uses it.
Summary
  • readonly prevents property reassignment after construction — the property can only be set in its declaration or the constructor.

  • Parameter properties combine property declaration and constructor assignment into a single shorthand.

  • Use public readonly id: string in the constructor signature to declare, type, and assign all at once.

  • readonly on an array/object reference does not make the contents immutable — use ReadonlyArray<T> or Readonly<T>.

  • The Readonly<T> utility type wraps all properties of T in readonly shallowly.

  • as const makes a value's type literal and all properties readonly at the type level.

  • Value objects, configuration classes, and DI containers are the most common real-world uses of readonly and parameter properties.