Phase 1 · Foundations · Lesson 1.4
BeginnerObject types: type aliases vs interfaces
How to describe the shape of objects: object type literals, `type` vs `interface`, optional and readonly properties, index signatures and a first look at `Record`.
20 min
Most of the data in a real program is objects: users, orders, config, API responses. TypeScript describes them by their shape, the properties they have and the type of each. There are two ways to name a shape, type and interface, and "which one should I use?" is one of the most asked TypeScript interview questions. By the end of this lesson you'll have a precise answer.
Object type literals
The simplest object type is written inline, like an object with types instead of values:
function printUser(user: { name: string; age: number }) {
console.log(`${user.name} is ${user.age}`);
}
printUser({ name: "Ada", age: 36 });
// @ts-expect-error -- Property 'age' is missing in type '{ name: string; }' but required in type '{ name: string; age: number; }'.
printUser({ name: "Ada" });Separate members with ; or , (both work). Once a shape is used in more than one place, give it a name.
Structural typing
TypeScript compares objects by shape, not by name. Any value with the right properties fits, even if it was never declared as that type, and extra properties are fine:
type Point = { x: number; y: number };
const p3 = { x: 1, y: 2, z: 3 };
const p: Point = p3; // fine: p3 has at least x and yExcess property checks
There's one exception. When you pass a fresh object literal straight to a typed spot, TypeScript flags unknown properties, because that's almost always a typo:
type Point = { x: number; y: number };
// @ts-expect-error -- Object literal may only specify known properties, and 'z' does not exist in type 'Point'.
const p: Point = { x: 1, y: 2, z: 3 };
const extra = { x: 1, y: 2, z: 3 };
const q: Point = extra; // no error: not a fresh literalSame object, different result. The check only applies to literals written directly where the type is expected.
Naming a shape: type and interface
Both declarations below describe the same shape, and for plain objects they are interchangeable:
type UserT = {
name: string;
age: number;
};
interface UserI {
name: string;
age: number;
}
const a: UserT = { name: "Ada", age: 36 };
const b: UserI = a; // same shape, fully compatibleA type alias gives a name to any type. An interface names only an object shape (including functions and classes' instance shapes). That's the root of every difference below.
Difference 1: only type can name unions, primitives and tuples
type Id = string | number;
type Pair = [string, number];
type Status = "active" | "banned";
interface Account {
id: Id; // interfaces can *use* them
status: Status;
}There's no interface Id = string | number. If the thing isn't an object shape, type is your only option.
Difference 2: interfaces merge, types don't
Declare the same interface twice in the same scope and TypeScript merges them into one. This is called declaration merging.
interface Settings {
theme: string;
}
interface Settings {
fontSize: number;
}
const s: Settings = { theme: "dark", fontSize: 14 }; // both members requiredA type alias with the same name is simply an error:
type Theme = { name: string };
type Theme = { dark: boolean };
// ~~~~~ Duplicate identifier 'Theme'. (reported on both declarations)Merging is how libraries and global declarations are extended: you can add a property to Window or to a library's interface from your own code. It's also a risk: an accidental second declaration with the same name silently widens your type. If a merged property is declared twice, both declarations must have the same type.
Difference 3: extends vs intersection &
Both let you build a shape from another:
interface Animal {
name: string;
}
interface Dog extends Animal {
bark(): void;
}
type AnimalT = { name: string };
type DogT = AnimalT & { bark(): void };They behave differently when properties conflict. extends checks compatibility and reports an error at the declaration:
interface Base {
id: string;
}
// @ts-expect-error -- Interface 'Child' incorrectly extends interface 'Base'.
interface Child extends Base {
id: number;
}An intersection just combines everything, and a conflicting property silently becomes never:
type Base = { id: string };
type Child = Base & { id: number };
declare const c: Child;
const id = c.id;
// ^? const id: neverNo error until someone tries to create a Child, and then the message is confusing. extends fails early and clearly.
Difference 4: error messages and performance
Interfaces keep their name in error messages and hovers; complex intersections are often expanded into their full structure. The compiler also caches the relationship between interfaces, so deep extends hierarchies check faster than equivalent deep intersections. The TypeScript team's own performance guide recommends interfaces over intersections for this reason.
Difference 5 (the subtle one): implicit index signatures
A type alias for an object literal type is assignable to Record<string, unknown>. An interface is not, because it could be merged with more members later:
type PointT = { x: number; y: number };
interface PointI { x: number; y: number }
const t: PointT = { x: 1, y: 2 };
const i: PointI = { x: 1, y: 2 };
const r1: Record<string, unknown> = t; // fine
// @ts-expect-error -- Type 'PointI' is not assignable to type 'Record<string, unknown>'.
const r2: Record<string, unknown> = i;You'll hit this when passing an interface-typed value to a function that takes "any string-keyed object".
Which should you prefer?
A reasonable rule that most style guides converge on:
interfacefor object shapes that others extend or that describe a public API: clear errors,extendschecks, faster checking, and merging when you need it.typefor everything else: unions, tuples, primitives, function types, and anything built with mapped or conditional types (later lessons).
Consistency matters more than the choice. Say that in an interview and explain the trade-offs.
Quick check
What happens when this code is compiled?
interface Box { width: number }
interface Box { height: number }
const b: Box = { width: 1, height: 2 };Optional properties
A ? after a property name makes it optional. Reading it gives T | undefined.
interface Profile {
name: string;
bio?: string;
}
const p: Profile = { name: "Ada" }; // bio can be left out
const bio = p.bio;
// ^? const bio: string | undefined
const length = p.bio?.length ?? 0; // handle the missing caseexactOptionalPropertyTypes
By default, bio?: string means both "may be missing" and "may be explicitly undefined". So { name: "Ada", bio: undefined } is allowed.
Those aren't the same at runtime: "bio" in p is true for one and false for the other, and Object.keys and spreads treat them differently. The exactOptionalPropertyTypes flag (not part of strict) separates them:
// with "exactOptionalPropertyTypes": true
interface Profile {
name: string;
bio?: string;
}
const a: Profile = { name: "Ada" }; // ok: missing
const b: Profile = { name: "Ada", bio: undefined }; // error:
// Type 'undefined' is not assignable to type 'string' with 'exactOptionalPropertyTypes: true'.
interface Loose {
bio?: string | undefined; // opt back in explicitly where undefined is allowed
}Reading p.bio still gives string | undefined, since a missing property reads as undefined.
Readonly properties
readonly stops reassigning a property after creation:
interface User {
readonly id: number;
readonly address: { city: string };
}
const u: User = { id: 1, address: { city: "Dhaka" } };
// @ts-expect-error -- Cannot assign to 'id' because it is a read-only property.
u.id = 2;
u.address.city = "Chattogram"; // allowed: readonly is shallowTwo gotchas:
- It's shallow. The
addressproperty can't be replaced, but the object inside is fully mutable. Mark nested propertiesreadonlytoo, or useReadonly<T>per level (a deep version needs a recursive type, covered later). - It doesn't survive assignment. A readonly type is assignable to a mutable one with the same shape, so an alias can write through:
interface Frozen {
readonly count: number;
}
interface Mutable {
count: number;
}
const frozen: Frozen = { count: 1 };
const alias: Mutable = frozen; // no error!
alias.count = 99; // frozen.count is now 99And like all types, readonly is erased at runtime. For a real freeze use Object.freeze, whose return type is Readonly<T>.
Quick check
Which line is a type error?
interface Post {
readonly tags: string[];
title: string;
}
declare const post: Post;
post.tags = [];
post.tags.push("ts");
post.title = "New";Index signatures
When you don't know the property names in advance, only their types, use an index signature:
interface Scores {
[player: string]: number;
}
const scores: Scores = { ada: 10, linus: 7 };
scores.grace = 12; // any string key is allowed
const s = scores.nobody;
// ^? const s: numberLook at that last line. scores.nobody doesn't exist; at runtime it's undefined. Yet the type says number. By default, index signatures assume every key is present.
noUncheckedIndexedAccess
This flag (not part of strict, but highly recommended) adds | undefined to every index-signature read, and to array element reads:
// with "noUncheckedIndexedAccess": true
declare const scores: { [player: string]: number }; // as above
const s = scores.nobody;
// ^? const s: number | undefined
const list = [1, 2, 3];
const first = list[0];
// ^? const first: number | undefined
for (const n of list) {} // n is number: iteration is always safeIt forces you to handle missing keys. It's noisy for arrays (you'll write list[0]! or check length), which is why it isn't on by default, but it catches a real class of undefined bugs.
Index signature rules
- Key types can be
string,number,symbol, template literal patterns like`data-${string}`, or unions of these. - Every named property must match the index signature's type:
interface Dictionary {
[key: string]: number;
count: number; // fine: number matches
// @ts-expect-error -- Property 'label' of type 'string' is not assignable to 'string' index type 'number'.
label: string;
}
interface Mixed {
[key: string]: number | string; // widen the signature instead
count: number;
label: string;
}- A
numberindex is really a string index in JavaScript (obj[1]isobj["1"]), so anumberindex signature's type must be assignable to thestringone when both exist.
A preview of Record
Record<K, V> is a built-in utility type for "an object whose keys are K and values are V". With string keys it's the same as an index signature:
type Scores = Record<string, number>; // same as { [key: string]: number }
type Role = "admin" | "editor" | "viewer";
type Permissions = Record<Role, boolean>;
const perms: Permissions = { admin: true, editor: true, viewer: false };
// @ts-expect-error -- Property 'viewer' is missing in type '{ admin: true; editor: false; }' but required in type 'Permissions'.
const partial: Permissions = { admin: true, editor: false };With a union of literal keys, Record is stricter than an index signature: every key is required, and no others are allowed. That makes it great for lookup tables that must cover every case. You'll see how it's built (a mapped type) in the utility types lesson.
Spot the error
A teammate wrote this config type. It compiles, but has two problems waiting to bite.
type Config = { host: string } & { port: number } & { port: string };
interface Env {
[key: string]: string;
}
function readPort(env: Env): number {
return Number(env.PORT.trim());
}Show the answer
- The intersection conflict.
portisnumber & string, which isnever. No value can ever satisfyConfig, but nothing errors until someone tries to build one. Aninterface ... extendswould have reported the conflict immediately. - The index signature lies.
env.PORTis typedstring, but the key may be missing, so.trim()can crash onundefined. EnablenoUncheckedIndexedAccess, or type the read as possibly missing and check it.
interface Config {
host: string;
port: number;
}
interface Env {
[key: string]: string | undefined;
}
function readPort(env: Env): number {
const raw = env.PORT;
if (raw === undefined) throw new Error("PORT is not set");
return Number(raw.trim());
}Try it
Swap interface for type in each declaration and see which errors appear or vanish.
interface Shape {
readonly kind: string;
area(): number;
}
interface Shape {
label?: string; // merged into the declaration above
}
interface Circle extends Shape {
radius: number;
}
const c: Circle = {
kind: "circle",
radius: 2,
area() {
return Math.PI * this.radius ** 2;
},
};
// @ts-expect-error -- Cannot assign to 'kind' because it is a read-only property.
c.kind = "square";
const byName: Record<string, Shape> = { c };
const found = byName["missing"]; // Shape, even though it's undefined at runtime▶ Try it in the TypeScript Playground
Recap
- Objects are typed structurally: any value with the required properties fits. Fresh object literals also get excess property checks.
typenames any type;interfacenames object shapes. Unions, tuples and primitives needtype.- Interfaces merge across declarations; duplicate type aliases are an error.
extendsreports conflicting properties;&silently makes themnever.- Interfaces aren't assignable to
Record<string, unknown>; object type aliases are. prop?: Treads asT | undefined;exactOptionalPropertyTypesforbids assigningundefinedexplicitly.readonlyis shallow, compile-time only, and a readonly type can be assigned to a mutable one.- Index signatures assume every key exists;
noUncheckedIndexedAccessadds| undefined. Named properties must match the signature. Record<K, V>with literal keys requires every key.
Interview cards
1 / 8