AniUI Academy

Object Types and Interfaces

Describing the shape of an object with an inline type or an interface, optional and readonly properties, and index signatures for dynamic keys.

9 min read

Most real data isn't a single primitive — it's an object with several named fields. This lesson covers describing that shape, first inline and then with the more reusable interface syntax.

Inline object types

An object type literal describes a shape directly, right where you need it:

function printLesson(lesson: { title: string; minutes: number }) {
  console.log(`${lesson.title} — ${lesson.minutes} min`);
}
 
printLesson({ title: "Object Types", minutes: 9 });

This works, but it's not reusable — if three functions all take a "lesson" shaped object, you'd be repeating that literal three times, and a change to the shape means updating it in three places.

Naming the shape with an interface

An interface gives an object type a name you can reuse:

interface Lesson {
  title: string;
  minutes: number;
}
 
function printLesson(lesson: Lesson) {
  console.log(`${lesson.title} — ${lesson.minutes} min`);
}
 
const lesson: Lesson = { title: "Object Types", minutes: 9 };
printLesson(lesson);

Remember from the first lesson that TypeScript is structural: lesson doesn't need to be declared as a Lesson to satisfy this function — it just needs the right shape. The interface is a name for a shape, not a runtime tag stamped onto the value.

Optional properties

A ? after a property name makes it possible to omit that property entirely — not merely to set it to undefined, but to leave it out:

interface Lesson {
  title: string;
  minutes: number;
  published?: boolean;
}
 
const draft: Lesson = { title: "Drafting", minutes: 5 }; // fine — published omitted

Reading an optional property gives you boolean | undefined, so code that uses it usually needs a narrowing check or a default:

function status(lesson: Lesson) {
  return lesson.published ? "live" : "draft"; // handles undefined too, via falsiness
}

readonly properties

readonly blocks reassignment of that property after the object is constructed — enforced by the compiler, not at runtime:

interface Lesson {
  readonly slug: string;
  title: string;
}
 
const lesson: Lesson = { slug: "object-types", title: "Object Types" };
lesson.title = "Renamed"; // fine
lesson.slug = "renamed";
// Cannot assign to 'slug' because it is a read-only property.

Because readonly is compile-time only, it doesn't make the underlying object literally immutable — Object.freeze() is the runtime equivalent, and the two are unrelated to each other as far as the type system is concerned.

Index signatures

Sometimes an object's keys aren't known in advance — think of a dictionary built from user input or an API response. An index signature describes that:

interface WordCounts {
  [word: string]: number;
}
 
const counts: WordCounts = {};
counts.typescript = 3;
counts["the"] = 12;
 
counts.typescript; // number
counts.missing;    // number — TypeScript trusts the signature, even though this key was never set

That last line is a real sharp edge: TypeScript can't verify a specific key actually exists at runtime, so counts.missing type-checks as number but is actually undefined if you look it up. The noUncheckedIndexedAccess compiler option (not on by default, even under strict) closes this gap by typing every index access as T | undefined instead.

Nesting and extending

Interfaces can reference other interfaces, and can extend one another to build up a shape:

interface Address {
  city: string;
  country: string;
}
 
interface User {
  name: string;
  address: Address;
}
 
interface AdminUser extends User {
  permissions: string[];
}
 
const admin: AdminUser = {
  name: "Kwame",
  address: { city: "Accra", country: "Ghana" },
  permissions: ["publish", "delete"],
};

extends copies every member of User into AdminUser, then adds permissions on top — the same idea as a subclass, applied to a pure type rather than a runtime class.

Declaration merging

A feature genuinely unique to interface (as opposed to type, covered next lesson): declaring the same interface name twice in the same scope merges the two declarations into one, combined shape.

interface Shape {
  area(): number;
}
 
interface Shape {
  color: string;
}
 
// Shape now requires both area() and color.
const square: Shape = {
  area: () => 16,
  color: "blue",
};

This looks like a strange footgun in isolation, but it's exactly the mechanism libraries use to let you extend a type they defined — augmenting Window with a global your script adds, for instance. It comes back in the modules lesson later in this track.

Try it yourself

Try it yourself
Loading playground...

What to remember

  • An interface names a reusable object shape; TypeScript still checks it structurally, not by declared label.
  • ? allows a property to be omitted entirely; readonly blocks reassignment, at compile time only.
  • An index signature ({ [key: string]: T }) types a dictionary-like object with unknown keys but uniform value types.
  • Interfaces support extends and declaration merging — two features type aliases don't have, covered next.

Check yourself

4 questions · pass 3/4 to unlock Type Aliases vs Interfaces

up to 50
  1. 1.Given interface User { name: string; age?: number }, is { name: "Amara" } a valid User?

  2. 2.What does marking a property readonly actually prevent?

  3. 3.What is an index signature like { [key: string]: number } for?

  4. 4.Given interface Shape { area(): number } and interface Shape { color: string } declared separately in the same scope, what happens?

4 left to answer