You pass an object to a function. The function mutates it. Somewhere else in your code, something breaks because the object no longer has the shape you expected. You spend two hours debugging. You find the mutation. You add a comment: "// Don't mutate this object." Six months later, someone (maybe you) mutates it anyway.
This is the story of every JavaScript codebase I've ever worked in. Objects are the primary currency of JavaScript — you pass them, return them, store them, transform them. And in plain JavaScript, objects lie. They say they have a property. They don't. They say they're immutable. They're not. They say they conform to a shape. They conform to whatever the last function that touched them decided.
TypeScript fixes this. With interfaces and type aliases, you can define object shapes that the compiler enforces. Objects that can't lie about their structure. Objects that make invalid states impossible.
Interfaces: The Shape of Things
An interface is a contract that an object must fulfill:
interface User {
name: string;
age: number;
email: string;
isAdmin: boolean;
}
const alice: User = {
name: "Alice",
age: 30,
email: "alice@example.com",
isAdmin: true,
};
If you miss a property, TypeScript complains. If you add an extra property, TypeScript complains. If you use the wrong type, TypeScript complains. The object must EXACTLY match the interface.
Optional Properties
Not every property is required:
interface User {
name: string;
email: string;
phone?: string; // Optional — can be omitted
age?: number; // Optional — can be omitted
}
const bob: User = {
name: "Bob",
email: "bob@example.com",
// phone and age are optional — no error
};
Optional properties are string | undefined when accessed. TypeScript forces you to handle the undefined case:
function formatPhone(user: User): string {
return user.phone.toUpperCase();
// ~~~~~~~~~~
// Error: 'user.phone' is possibly 'undefined'.
}
function formatPhoneSafe(user: User): string {
return user.phone?.toUpperCase() ?? "No phone";
}
Readonly Properties
Some properties should never change after creation:
interface User {
readonly id: number;
name: string;
email: string;
}
const user: User = { id: 1, name: "Alice", email: "alice@example.com" };
user.name = "Alice Johnson"; // OK
user.id = 2; // Error: Cannot assign to 'id' because it is a read-only property
readonly is compile-time only. At runtime, the property is still mutable. But the compiler won't let you assign to it. This is perfect for IDs, timestamps, and any value that should be set once and never changed.
Index Signatures
What if you don't know the property names in advance?
interface StringDictionary {
[key: string]: string;
}
const colors: StringDictionary = {
red: "#FF0000",
green: "#00FF00",
blue: "#0000FF",
};
colors.red; // Type: string
colors["green"]; // Type: string
colors.yellow = "#FFFF00"; // OK
The [key: string]: string syntax says "this object can have any number of string keys, and all values must be strings." You can use number or symbol as the key type too.
A more realistic example:
interface Cache<T> {
[key: string]: T;
}
const userCache: Cache<User> = {};
userCache["user-1"] = alice;
userCache["user-2"] = bob;
const configCache: Cache<{ timeout: number }> = {};
configCache["api"] = { timeout: 5000 };
Extending Interfaces
Interfaces can build on other interfaces:
interface Person {
name: string;
age: number;
}
interface Employee extends Person {
employeeId: number;
department: string;
}
const employee: Employee = {
name: "Alice",
age: 30,
employeeId: 12345,
department: "Engineering",
};
Employee inherits all properties from Person and adds its own. You can extend multiple interfaces:
interface Flyable {
fly(): void;
}
interface Swimmable {
swim(): void;
}
interface Duck extends Flyable, Swimmable {
quack(): void;
}
Type Aliases: The Other Way
Type aliases are an alternative syntax for defining object shapes:
type User = {
name: string;
age: number;
email: string;
isAdmin: boolean;
};
For object shapes, interfaces and type aliases are largely interchangeable. Both support optional properties, readonly, and index signatures. So which should you use?
Interfaces vs. Type Aliases: The Decision
| Feature | Interface | Type Alias |
|---|---|---|
| Object shapes | ✅ | ✅ |
| Extending | extends | & (intersection) |
| Declaration merging | ✅ | ❌ |
| Union types | ❌ | ✅ |
| Primitives | ❌ | ✅ |
| Tuples | ❌ | ✅ |
| Functions | Can use call signature | Can use => syntax |
My rule of thumb:
- Use interfaces for object shapes that represent data entities (User, Product, Order). Interfaces are more idiomatic for "the shape of a thing."
- Use type aliases for everything else: unions, intersections, primitives, tuples, function types, and complex type manipulations.
- Be consistent within your codebase. Pick a convention and stick with it.
The one technical difference that matters: interfaces support declaration merging. If you declare two interfaces with the same name, they merge:
interface User {
name: string;
}
interface User {
age: number;
}
// User is now { name: string; age: number }
This is useful for augmenting third-party types (we'll cover this in Chapter 16). Type aliases don't merge — duplicate names are an error.
Intersection Types: Combining Shapes
Type aliases use & to combine types:
type Person = {
name: string;
age: number;
};
type Employee = Person & {
employeeId: number;
department: string;
};
// Employee = { name: string; age: number; employeeId: number; department: string }
Intersection types combine ALL properties from ALL constituent types. If two types have the same property with different types, the intersection is... interesting:
type A = { x: string };
type B = { x: number };
type C = A & B;
// C = { x: never } — x must be both string AND number, which is impossible
This is rarely what you want. If you're combining types with overlapping properties, make sure the types are compatible.
The object Type vs. Object Literal Types
TypeScript has a built-in object type, but it's almost never what you want:
let obj: object = { name: "Alice" };
obj.name; // Error: Property 'name' does not exist on type 'object'
object means "any non-primitive value." It doesn't know about specific properties. Use a proper interface or type alias instead.
Excess Property Checking
TypeScript has a special check for object literals:
interface User {
name: string;
email: string;
}
const user: User = {
name: "Alice",
email: "alice@example.com",
age: 30, // Error: 'age' does not exist in type 'User'
};
But this works:
const data = {
name: "Alice",
email: "alice@example.com",
age: 30,
};
const user: User = data; // OK — no excess property check
Why? Because excess property checking only applies to object LITERALS. If you assign an existing variable, TypeScript uses structural typing — if the variable has at least the required properties, it's compatible.
This is by design. It prevents typos in object literals (the most common source of excess property bugs) while allowing flexibility when passing objects around.
Structural Typing: Shape Matters, Not Name
TypeScript uses structural typing, not nominal typing. Two types are compatible if they have the same shape, regardless of their names:
interface Point2D {
x: number;
y: number;
}
interface Vector2D {
x: number;
y: number;
}
const point: Point2D = { x: 10, y: 20 };
const vector: Vector2D = point; // OK — same shape
This is different from languages like Java or C#, where two classes with the same fields are different types. In TypeScript, if it walks like a duck and quacks like a duck, it's a duck — even if you named it Mallard.
Structural typing is one of TypeScript's best design decisions. It means you can use objects from different libraries interchangeably as long as they have the same shape. No adapters, no wrappers, no ceremony.
Designing Types That Make Invalid States Impossible
The best TypeScript types don't just describe valid data — they make invalid data unrepresentable. This is a concept from functional programming, and it's one of the most powerful ideas in type-driven design.
Consider a fetchState for an API call:
// BAD: Invalid states are representable
interface FetchState {
data: Data | null;
error: Error | null;
isLoading: boolean;
}
// You can have: { data: {...}, error: {...}, isLoading: true }
// That makes no sense! How can you have data AND be loading?
A better design:
// GOOD: Invalid states are impossible
type FetchState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: Data }
| { status: "error"; error: Error };
// You CANNOT have data without status: "success"
// You CANNOT have error without status: "error"
// You CANNOT be loading AND have data
This is a discriminated union (we'll explore these fully in Chapter 7). The key insight: the type system can encode business logic. When you design types that make invalid states impossible, you eliminate entire categories of bugs.
Another example:
// BAD: Any combination is possible
interface User {
name: string | null;
email: string | null;
isAuthenticated: boolean;
}
// GOOD: Authenticated users always have name and email
type User =
| { isAuthenticated: false }
| { isAuthenticated: true; name: string; email: string };
When isAuthenticated is true, name and email are guaranteed to exist. When it's false, they don't exist. The type system enforces this. You can't forget to check isAuthenticated before accessing name — the compiler won't let you.
The satisfies Operator
TypeScript 4.9 introduced the satisfies operator, and it's one of those features that, once you start using it, you wonder how you lived without it:
type Color = "red" | "green" | "blue";
type RGB = [number, number, number];
const palette = {
red: [255, 0, 0],
green: [0, 255, 0],
blue: [0, 0, 255],
// purple: [128, 0, 128], // Would be an error — not in Color
} satisfies Record<Color, RGB>;
// palette.red is still [255, 0, 0] — not widened to RGB
// palette.red[0] is 255 — not widened to number
// You get the EXACT literal types, not the wider types
satisfies checks that a value conforms to a type WITHOUT widening the inferred type. It's perfect for configuration objects where you want both validation AND precise inference.
Without satisfies:
const palette: Record<Color, RGB> = {
red: [255, 0, 0],
green: [0, 255, 0],
blue: [0, 0, 255],
};
palette.red[0]; // Type: number (widened — we lost the literal 255)
With satisfies:
const palette = {
red: [255, 0, 0],
green: [0, 255, 0],
blue: [0, 0, 255],
} satisfies Record<Color, RGB>;
palette.red[0]; // Type: 255 (preserved!)
Try This: Object Types
- Define an
interface Productwithid(readonly number),name(string),price(number),category(string), andtags(optional string array). - Create a
type DiscountedProductthat extendsProductwithdiscountPercentage(number). - Create a
type Configwith an index signature[key: string]: string | number | boolean. Create a config object that satisfies it. - Design a
type ApiState<T>that can be{ status: "idle" },{ status: "loading" },{ status: "success"; data: T }, or{ status: "error"; error: Error }. Write a function that handles all four states. - Use
satisfiesto create a validated configuration object while preserving literal types.
Time needed: 20 minutes.
What to notice: How the ApiState type makes it impossible to access data without first checking status. How satisfies gives you both validation and precise types. How readonly catches accidental mutations at compile time.
The Revelation
You now know how to type objects — the nouns of your program. But the real power of TypeScript's type system isn't in defining individual shapes. It's in combining them. Union types. Intersection types. Discriminated unions. Types that express "this OR that" and "this AND that."
That's the next chapter. And it's where TypeScript goes from "useful" to "mind-expanding."
In the next chapter: union types, intersection types, discriminated unions, and how to use them to make invalid states impossible — not just in theory, but in every function you write.