In the previous tutorial, we learned how to type functions. Now let’s learn how to describe the shape of objects — one of the most important skills in TypeScript.

By the end of this tutorial, you will know how to define object types, use interfaces, extend them, and choose between interfaces and type aliases.

Object Types

You can describe an object’s shape by listing its properties and types:

let user: { name: string; age: number } = {
  name: "Alex",
  age: 25,
};

TypeScript checks that the object matches the shape:

let user: { name: string; age: number } = {
  name: "Alex",
  // Error: Property 'age' is missing in type '{ name: string; }'
};

You can use object types in function parameters:

function printUser(user: { name: string; age: number }): void {
  console.log(`${user.name} is ${user.age} years old`);
}

printUser({ name: "Alex", age: 25 }); // OK
printUser({ name: "Alex" });           // Error: 'age' is missing

Writing { name: string; age: number } everywhere gets repetitive. That is where interfaces come in.

Interfaces

An interface defines a reusable shape for objects:

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

const user: User = {
  name: "Alex",
  age: 25,
  email: "alex@example.com",
};

Now you can use User anywhere instead of writing out the full object type:

function printUser(user: User): void {
  console.log(`${user.name} (${user.email})`);
}

function getUsers(): User[] {
  return [
    { name: "Alex", age: 25, email: "alex@example.com" },
    { name: "Sam", age: 30, email: "sam@example.com" },
  ];
}

Interfaces make your code cleaner and easier to maintain. Change the interface once, and every function that uses it gets updated.

Optional Properties

Add ? after a property name to make it optional:

interface User {
  name: string;
  age: number;
  email?: string;  // optional
}

const user1: User = { name: "Alex", age: 25 };                        // OK
const user2: User = { name: "Sam", age: 30, email: "sam@example.com" }; // OK

Optional properties have undefined as a possible value. When you access them, TypeScript knows they might not exist:

function sendEmail(user: User): void {
  if (user.email) {
    console.log(`Sending email to ${user.email}`);
  } else {
    console.log("No email address");
  }
}

Readonly Properties

Use readonly to prevent a property from being changed after creation:

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

const user: User = { id: 1, name: "Alex", age: 25 };
user.name = "Sam";  // OK — name is not readonly
user.id = 2;        // Error: Cannot assign to 'id' because it is a read-only property

Use readonly for IDs, creation dates, and other values that should never change.

Note: readonly is a compile-time check only. At runtime, the property can still be changed. TypeScript trusts you to follow the rules.

Extending Interfaces

You can build new interfaces from existing ones with extends:

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

interface Admin extends User {
  role: string;
  permissions: string[];
}

const admin: Admin = {
  name: "Alex",
  age: 25,
  email: "alex@example.com",
  role: "admin",
  permissions: ["read", "write", "delete"],
};

Admin has everything from User plus role and permissions.

You can extend multiple interfaces:

interface HasName {
  name: string;
}

interface HasEmail {
  email: string;
}

interface Contact extends HasName, HasEmail {
  phone: string;
}

const contact: Contact = {
  name: "Alex",
  email: "alex@example.com",
  phone: "+49-123-456",
};

This is great for composing small, focused interfaces into bigger ones.

Interface vs Type Alias

TypeScript has two ways to name an object shape: interface and type. They are very similar:

// Interface
interface User {
  name: string;
  age: number;
}

// Type alias
type UserType = {
  name: string;
  age: number;
};

Both work in the same places. But there are differences:

Featureinterfacetype
Extendextends keyword& intersection
Merge (declaration merging)YesNo
Union typesNoYes
Primitive typesNoYes
Mapped typesNoYes

When to Use Each

Use interface when:

  • Defining the shape of objects
  • You want to extend the shape later
  • You’re working with classes

Use type when:

  • Defining union types: type Status = "active" \| "inactive"
  • Defining tuple types: type Pair = [string, number]
  • Combining types with intersections

Simple rule: Use interface for objects, type for everything else. When in doubt, use interface.

Declaration Merging

One unique feature of interface is declaration merging. If you declare the same interface twice, TypeScript combines them:

interface User {
  name: string;
}

interface User {
  age: number;
}

// User now has both name and age
const user: User = {
  name: "Alex",
  age: 25,
};

This is useful when you need to extend a third-party library’s types. But it can also cause confusion, so use it intentionally.

type does not support declaration merging. Declaring the same type twice is an error.

Index Signatures

Sometimes you don’t know all property names in advance. Use an index signature:

interface Dictionary {
  [key: string]: string;
}

const translations: Dictionary = {
  hello: "hola",
  goodbye: "adios",
  thanks: "gracias",
};

// You can add any string key
translations["please"] = "por favor";

The [key: string]: string means “any string key maps to a string value.”

You can combine index signatures with known properties:

interface Config {
  name: string;
  version: number;
  [key: string]: string | number;
}

const config: Config = {
  name: "my-app",
  version: 1,
  port: 3000,
  host: "localhost",
};

Note: When you have an index signature, all known properties must have types compatible with the index signature type.

Excess Property Checking

TypeScript has a special rule: when you assign an object literal directly to a typed variable, it checks for extra properties:

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

// Error: Object literal may only specify known properties, and 'email' does not exist in type 'User'
const user: User = {
  name: "Alex",
  age: 25,
  email: "alex@example.com", // Extra property — error!
};

This catches typos and accidental extra fields. But it only applies to object literals assigned directly. If you pass through a variable, the check does not apply:

const data = {
  name: "Alex",
  age: 25,
  email: "alex@example.com",
};

const user: User = data; // OK — no excess property checking

This works because of duck typing (structural typing).

Duck Typing (Structural Typing)

TypeScript uses structural typing. If an object has all the required properties, it fits the type — even if it has extra properties:

interface HasName {
  name: string;
}

function greet(thing: HasName): string {
  return `Hello, ${thing.name}!`;
}

const user = { name: "Alex", age: 25, email: "alex@example.com" };
greet(user); // OK — user has 'name', that's enough

TypeScript does not care that user has age and email. It only checks that user has name: string.

This is called “duck typing”: if it looks like a duck and quacks like a duck, it is a duck.

Nested Objects

Interfaces can describe nested structures:

interface Address {
  street: string;
  city: string;
  country: string;
}

interface User {
  name: string;
  age: number;
  address: Address;
}

const user: User = {
  name: "Alex",
  age: 25,
  address: {
    street: "123 Main St",
    city: "Berlin",
    country: "Germany",
  },
};

Break complex objects into smaller interfaces. It makes your code more readable and reusable.

Quick Practice

Create src/objects.ts and try these:

// Define interfaces
interface Product {
  readonly id: number;
  name: string;
  price: number;
  description?: string;
}

interface CartItem extends Product {
  quantity: number;
}

// Create objects
const laptop: Product = {
  id: 1,
  name: "Laptop",
  price: 999.99,
  description: "A fast laptop for developers",
};

const cartItem: CartItem = {
  ...laptop,
  quantity: 2,
};

// Function using interfaces
function getTotal(items: CartItem[]): number {
  let total = 0;
  for (const item of items) {
    total += item.price * item.quantity;
  }
  return total;
}

// Index signature example
interface Inventory {
  [productName: string]: number;
}

const stock: Inventory = {
  laptop: 50,
  keyboard: 200,
  mouse: 150,
};

// Test it
console.log(`Cart item: ${cartItem.name} x${cartItem.quantity}`);
console.log(`Total: $${getTotal([cartItem])}`);
console.log(`Laptops in stock: ${stock["laptop"]}`);

Run it:

npx tsx src/objects.ts

Output:

Cart item: Laptop x2
Total: $1999.98
Laptops in stock: 50

What is Next?

In the next tutorial, we will learn about union types, literal types, and type aliases — the patterns that make TypeScript truly powerful.