Skip to main content

Objects and Interfaces

Most real data isn't a lone string or number — it's objects: users, orders, API responses. This page is about describing their shape.

Object Types

You can type an object inline by listing its properties:

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

printUser({ name: "Rizwan", age: 29 }); // ✅
printUser({ name: "Sara" }); // ❌ Property 'age' is missing
printUser({ name: "Ali", age: "thirty" }); // ❌ 'age' must be number

Inline types get unreadable fast. For anything used more than once, give the shape a name.

Interfaces

An interface names an object shape:

interface User {
id: number;
name: string;
email: string;
isActive: boolean;
}

const rizwan: User = {
id: 1,
name: "Rizwan Ashiq",
email: "rizwan@example.com",
isActive: true,
};

function deactivate(user: User): User {
return { ...user, isActive: false };
}

Now every function dealing with users shares one definition. Rename a property in the interface and the compiler points at every place that needs updating — this is what makes refactoring large codebases sane.

Type Alias

A type alias can name an object shape too, and the syntax is nearly identical:

type Product = {
id: number;
title: string;
price: number;
};

const laptop: Product = { id: 1, title: "ThinkPad", price: 1200 };

Interface vs type — which one?

For plain object shapes they're interchangeable. The practical differences:

  • type can name anything — unions, tuples, primitives — while interface only describes object shapes:
type OrderStatus = "pending" | "shipped" | "delivered"; // interface can't do this
type Coordinate = [number, number]; // or this
type Id = string | number; // or this
  • interface supports declaration merging — declaring the same interface twice merges them. Mostly useful for extending third-party library types.

My convention (and a common one): interface for object shapes, type for everything else. Don't lose sleep over it — consistency matters more than the choice.

Optional Properties

A question mark makes a property optional:

interface UserProfile {
name: string;
email: string;
bio?: string; // may be missing
avatarUrl?: string; // may be missing
}

const minimal: UserProfile = { name: "Sara", email: "sara@example.com" }; // ✅

The type of an optional property inside the object is "its type or undefined", so TypeScript forces you to handle the missing case:

function bioLength(profile: UserProfile): number {
return profile.bio.length; // ❌ 'profile.bio' is possibly 'undefined'
}

function bioLengthSafe(profile: UserProfile): number {
return profile.bio?.length ?? 0; // ✅ optional chaining + fallback
}

That compile error is TypeScript preventing the classic Cannot read properties of undefined crash.

Readonly Properties

readonly blocks reassignment after creation:

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

const config: Config = { apiUrl: "https://api.example.com", maxRetries: 3 };

config.maxRetries = 5; // ❌ Cannot assign to 'maxRetries' because it is a read-only property

I use this for config objects and IDs — things that should be set once. Note it's a compile-time guarantee only; the emitted JavaScript object is still mutable at runtime.

Extending Interfaces

Interfaces can build on other interfaces with extends:

interface Person {
name: string;
email: string;
}

interface Employee extends Person {
employeeId: number;
department: string;
}

const dev: Employee = {
name: "Rizwan",
email: "rizwan@example.com",
employeeId: 42,
department: "Engineering",
};

An Employee has everything a Person has, plus its own fields. Any function that accepts a Person also accepts an Employee — the shape is compatible.

Type aliases achieve the same with an intersection (&):

type Timestamped = { createdAt: Date; updatedAt: Date };
type Post = Timestamped & {
title: string;
body: string;
};

Index Signatures

Sometimes you don't know the property names ahead of time — only their types. Think of a map of error messages keyed by field name:

interface ValidationErrors {
[field: string]: string;
}

const errors: ValidationErrors = {
email: "Email is invalid",
password: "Password too short",
};

errors.username = "Username is taken"; // ✅ any string key works
errors.age = 42; // ❌ values must be string

You can mix known properties with an index signature, as long as they're compatible:

interface ApiHeaders {
"content-type": string;
[header: string]: string;
}
Prefer Record for simple maps

For a plain "keys of type X, values of type Y" map, the built-in Record utility type reads better than writing an index signature by hand:

const errors: Record<string, string> = {
email: "Email is invalid",
};

const stock: Record<string, number> = { laptop: 12, mouse: 40 };
Excess property checks

When you pass an object literal directly, TypeScript is extra strict and rejects unknown properties:

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

function plot(p: Point): void {
console.log(p.x, p.y);
}

plot({ x: 1, y: 2, z: 3 }); // ❌ 'z' does not exist in type 'Point'

const p3d = { x: 1, y: 2, z: 3 };
plot(p3d); // ✅ allowed — extra props on a variable are fine

This catches typos in literals (like colour instead of color) at the exact place you write them.

Recap

  • Name your object shapes — interface for objects, type for unions, tuples, and everything else
  • ? marks optional properties and forces you to handle the undefined case
  • readonly prevents reassignment at compile time
  • extends (interfaces) and & (type aliases) compose shapes
  • Index signatures and Record type objects with dynamic keys

Next: typing functions properly, and the feature that makes reusable typed code possible — generics.