From Zero to AI

Lesson 3.6: Optional and Readonly Properties

Duration: 50 minutes

Learning Objectives

By the end of this lesson, you will be able to:

  • Mark properties as optional using the ? modifier
  • Protect properties from modification with readonly
  • Handle optional properties safely in your code
  • Design interfaces that accurately model real-world data

Optional Properties

Not all objects have all properties filled in. Optional properties handle this reality.

The Problem Without Optional Properties

interface User {
  id: string;
  name: string;
  email: string;
  phone: string; // What if user didn't provide a phone?
}

const alice: User = {
  id: 'user_1',
  name: 'Alice',
  email: 'alice@example.com',
  // Error! Property 'phone' is missing
};

The Solution: Optional Properties

Add ? after the property name to make it optional:

interface User {
  id: string;
  name: string;
  email: string;
  phone?: string; // Optional - may or may not exist
}

const alice: User = {
  id: 'user_1',
  name: 'Alice',
  email: 'alice@example.com',
  // No error - phone is optional
};

const bob: User = {
  id: 'user_2',
  name: 'Bob',
  email: 'bob@example.com',
  phone: '555-0123', // Also valid - phone is provided
};

Working with Optional Properties

Checking for Existence

Optional properties might be undefined, so check before using:

interface User {
  name: string;
  nickname?: string;
}

function greet(user: User) {
  // Without check - TypeScript warns about possible undefined
  console.log(user.nickname.toUpperCase()); // Error! Object is possibly 'undefined'

  // With check - safe to use
  if (user.nickname) {
    console.log(`Hey, ${user.nickname.toUpperCase()}!`);
  } else {
    console.log(`Hello, ${user.name}!`);
  }
}

Using Default Values

Provide defaults for missing properties:

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

function applySettings(settings: Settings) {
  const theme = settings.theme ?? 'light';
  const fontSize = settings.fontSize ?? 14;
  const notifications = settings.notifications ?? true;

  console.log(`Theme: ${theme}, Size: ${fontSize}, Notifications: ${notifications}`);
}

applySettings({}); // Uses all defaults
applySettings({ theme: 'dark' }); // Uses dark theme, defaults for rest
applySettings({ theme: 'dark', fontSize: 18, notifications: false }); // All specified

The ?? operator (nullish coalescing) returns the right side if the left is null or undefined.

Optional Chaining

Access nested optional properties safely:

interface Company {
  name: string;
  address?: {
    street: string;
    city: string;
  };
}

function getCity(company: Company): string {
  // Without optional chaining - verbose
  if (company.address) {
    return company.address.city;
  }
  return 'Unknown';

  // With optional chaining - concise
  return company.address?.city ?? 'Unknown';
}

Optional Function Parameters

Functions can have optional parameters too:

function createUser(name: string, email: string, age?: number) {
  const user = {
    name,
    email,
    age: age ?? 0, // Default to 0 if not provided
  };
  return user;
}

createUser('Alice', 'alice@example.com'); // age is undefined
createUser('Bob', 'bob@example.com', 28); // age is 28

Rule: Optional parameters must come after required parameters.

// Error! Required parameter cannot follow optional
function badFunction(optional?: string, required: string) {}

// Correct
function goodFunction(required: string, optional?: string) {}

Readonly Properties

Some properties should never change after creation. Use readonly to enforce this.

Basic Readonly

interface User {
  readonly id: string;
  name: string;
  email: string;
}

const user: User = {
  id: 'user_123',
  name: 'Alice',
  email: 'alice@example.com',
};

user.name = 'Alicia'; // OK - name can change
user.id = 'user_456'; // Error! Cannot assign to 'id' because it is a read-only property

Common Use Cases for Readonly

IDs and Timestamps:

interface Entity {
  readonly id: string;
  readonly createdAt: Date;
  updatedAt: Date; // Can be modified
}

Configuration Objects:

interface AppConfig {
  readonly apiUrl: string;
  readonly maxRetries: number;
  readonly timeout: number;
}

const config: AppConfig = {
  apiUrl: 'https://api.example.com',
  maxRetries: 3,
  timeout: 5000,
};

config.apiUrl = 'https://other.com'; // Error! Read-only

Immutable Data:

interface Point {
  readonly x: number;
  readonly y: number;
}

function movePoint(point: Point, dx: number, dy: number): Point {
  // Cannot modify the original point
  // point.x += dx; // Error!

  // Return a new point instead
  return {
    x: point.x + dx,
    y: point.y + dy,
  };
}

Readonly Arrays

Use ReadonlyArray<T> or readonly T[] for arrays that should not be modified:

const numbers: readonly number[] = [1, 2, 3, 4, 5];

numbers.push(6); // Error! Property 'push' does not exist on type 'readonly number[]'
numbers[0] = 10; // Error! Index signature in type 'readonly number[]' only permits reading
numbers.pop(); // Error! Property 'pop' does not exist

// Reading is fine
console.log(numbers[0]); // OK
console.log(numbers.length); // OK
const doubled = numbers.map((n) => n * 2); // OK - creates new array

Function Parameters with Readonly Arrays

function sum(numbers: readonly number[]): number {
  // Cannot accidentally modify the input
  // numbers.push(0); // Error!

  return numbers.reduce((a, b) => a + b, 0);
}

const values = [1, 2, 3, 4, 5];
console.log(sum(values)); // 15
// values array is unchanged

Combining Optional and Readonly

Properties can be both optional and readonly:

interface User {
  readonly id: string;
  name: string;
  email: string;
  readonly createdAt: Date;
  phone?: string;
  readonly deletedAt?: Date; // Optional AND readonly
}

const user: User = {
  id: 'user_1',
  name: 'Alice',
  email: 'alice@example.com',
  createdAt: new Date(),
  // phone and deletedAt are optional
};

// Can add phone later (it's optional but not readonly)
user.phone = '555-0123'; // OK

// Cannot change deletedAt once set (readonly)
// user.deletedAt = new Date(); // Error! Cannot assign - it's readonly

The Readonly Utility Type

TypeScript provides a utility type to make all properties readonly:

interface User {
  id: string;
  name: string;
  email: string;
}

type ReadonlyUser = Readonly<User>;

// Equivalent to:
// interface ReadonlyUser {
//   readonly id: string;
//   readonly name: string;
//   readonly email: string;
// }

const user: ReadonlyUser = {
  id: 'user_1',
  name: 'Alice',
  email: 'alice@example.com',
};

user.name = 'Bob'; // Error! All properties are readonly

The Partial Utility Type

Make all properties optional with Partial<T>:

interface User {
  id: string;
  name: string;
  email: string;
  age: number;
}

type PartialUser = Partial<User>;

// Equivalent to:
// interface PartialUser {
//   id?: string;
//   name?: string;
//   email?: string;
//   age?: number;
// }

// Useful for update functions
function updateUser(id: string, updates: Partial<User>) {
  // Can update any subset of properties
}

updateUser('user_1', { name: 'Alice' }); // OK - only updating name
updateUser('user_1', { email: 'new@example.com', age: 29 }); // OK - updating multiple

Practical Examples

Example 1: User Profile System

interface UserProfile {
  readonly userId: string;
  readonly createdAt: Date;
  username: string;
  displayName: string;
  bio?: string;
  website?: string;
  location?: string;
  avatar?: string;
}

function createProfile(userId: string, username: string): UserProfile {
  return {
    userId,
    createdAt: new Date(),
    username,
    displayName: username,
  };
}

function updateProfile(
  profile: UserProfile,
  updates: Partial<Omit<UserProfile, 'userId' | 'createdAt'>>
): UserProfile {
  return {
    ...profile,
    ...updates,
  };
}

let profile = createProfile('user_1', 'alice');
profile = updateProfile(profile, {
  displayName: 'Alice Smith',
  bio: 'TypeScript enthusiast',
});

Example 2: Form State

interface FormField {
  value: string;
  error?: string;
  touched?: boolean;
  readonly name: string;
}

interface LoginForm {
  readonly formId: string;
  email: FormField;
  password: FormField;
  rememberMe?: boolean;
}

const loginForm: LoginForm = {
  formId: 'login_form',
  email: {
    name: 'email',
    value: '',
  },
  password: {
    name: 'password',
    value: '',
  },
};

// Update field values
loginForm.email.value = 'alice@example.com';
loginForm.email.touched = true;

// Cannot change field names
// loginForm.email.name = "user_email"; // Error!

Example 3: Immutable Configuration

interface DatabaseConfig {
  readonly host: string;
  readonly port: number;
  readonly database: string;
  readonly ssl?: boolean;
}

interface ServerConfig {
  readonly port: number;
  readonly hostname: string;
}

interface AppConfig {
  readonly database: DatabaseConfig;
  readonly server: ServerConfig;
  debug?: boolean; // Can be toggled
}

const config: AppConfig = {
  database: {
    host: 'localhost',
    port: 5432,
    database: 'myapp',
    ssl: true,
  },
  server: {
    port: 3000,
    hostname: 'localhost',
  },
  debug: true,
};

// Can toggle debug mode
config.debug = false; // OK

// Cannot change server config
// config.server.port = 8080; // Error!

Exercises

Exercise 1: Optional Profile Fields

Create an interface for a social media profile where only username and email are required:

Solution
interface SocialProfile {
  username: string;
  email: string;
  displayName?: string;
  bio?: string;
  avatarUrl?: string;
  website?: string;
  followers?: number;
  following?: number;
}

const minimalProfile: SocialProfile = {
  username: 'alice',
  email: 'alice@example.com',
};

const fullProfile: SocialProfile = {
  username: 'bob',
  email: 'bob@example.com',
  displayName: 'Bob Smith',
  bio: 'Developer',
  avatarUrl: 'https://example.com/avatar.jpg',
  website: 'https://bob.dev',
  followers: 1000,
  following: 500,
};

Exercise 2: Readonly Configuration

Create a readonly configuration for an API client:

Solution
interface ApiClientConfig {
  readonly baseUrl: string;
  readonly apiKey: string;
  readonly timeout: number;
  readonly retries: number;
  readonly headers: Readonly<Record<string, string>>;
}

const apiConfig: ApiClientConfig = {
  baseUrl: 'https://api.example.com',
  apiKey: 'secret_key_123',
  timeout: 5000,
  retries: 3,
  headers: {
    'Content-Type': 'application/json',
    Accept: 'application/json',
  },
};

// Cannot modify any config
// apiConfig.baseUrl = "https://other.com"; // Error!
// apiConfig.headers["Authorization"] = "Bearer token"; // Error!

Exercise 3: Update Function with Partial

Create a todo item interface and an update function:

Solution
interface Todo {
  readonly id: string;
  title: string;
  description?: string;
  completed: boolean;
  priority?: 'low' | 'medium' | 'high';
  readonly createdAt: Date;
  dueDate?: Date;
}

function createTodo(title: string): Todo {
  return {
    id: crypto.randomUUID(),
    title,
    completed: false,
    createdAt: new Date(),
  };
}

function updateTodo(todo: Todo, updates: Partial<Omit<Todo, 'id' | 'createdAt'>>): Todo {
  return {
    ...todo,
    ...updates,
  };
}

let myTodo = createTodo('Learn TypeScript');
myTodo = updateTodo(myTodo, {
  completed: true,
  priority: 'high',
});

console.log(myTodo);

Exercise 4: Safe Property Access

Write a function that safely accesses nested optional properties:

Solution
interface Company {
  name: string;
  headquarters?: {
    address?: {
      street?: string;
      city?: string;
      country?: string;
    };
  };
}

function getHeadquartersCity(company: Company): string {
  return company.headquarters?.address?.city ?? 'Unknown';
}

function getFullAddress(company: Company): string {
  const addr = company.headquarters?.address;
  if (!addr) {
    return 'No address available';
  }

  const parts = [addr.street, addr.city, addr.country].filter(Boolean);

  return parts.length > 0 ? parts.join(', ') : 'Incomplete address';
}

const techCorp: Company = {
  name: 'TechCorp',
  headquarters: {
    address: {
      city: 'San Francisco',
      country: 'USA',
    },
  },
};

console.log(getHeadquartersCity(techCorp)); // "San Francisco"
console.log(getFullAddress(techCorp)); // "San Francisco, USA"

const startupCo: Company = { name: 'StartupCo' };
console.log(getHeadquartersCity(startupCo)); // "Unknown"

Key Takeaways

  1. Optional properties use ?: They may or may not exist on the object
  2. Always check optional properties: Use if, ??, or ?. before accessing
  3. Readonly prevents modification: Use for IDs, timestamps, and config values
  4. Readonly arrays cannot be mutated: No push, pop, or index assignment
  5. Combine optional and readonly: Properties can be both
  6. Use Partial: Make all properties optional for update functions
  7. Use Readonly: Make all properties readonly for immutable objects

Resources

Resource Type Description
TypeScript Handbook: Optional Properties Documentation Official guide
TypeScript Handbook: Readonly Properties Documentation Official guide
TypeScript Handbook: Utility Types Documentation Partial, Readonly, and more

Next Lesson

You now have a complete toolkit for defining types in TypeScript. In the final lesson of this module, we will put everything together in a comprehensive practice exercise.

Continue to Lesson 3.7: Practice - Typing User Data