Skip to content
C Codeloom
TypeScript

TypeScript Interfaces and Type Aliases Explained

A complete beginner's guide to interfaces and type aliases in TypeScript — how to declare object shapes, when to choose each, and how to extend and combine them.

·9 min read · By Yash Kesharwani
Beginner 11 min read

What you'll learn

  • How to declare an object shape with interface and type
  • Optional and readonly properties
  • Index signatures for dictionary-like objects
  • How interfaces extend other interfaces
  • How type aliases combine with unions and intersections
  • A practical rule for choosing between the two

Prerequisites

  • You know TypeScript's basic types — see Basic Types

Once your programs start working with objects more complicated than { x: number, y: number }, you need a way to give those shapes a name and reuse them. TypeScript gives you two tools for the job: interfaces and type aliases. They overlap heavily, and beginners often spend more time worrying about which to pick than the choice actually deserves. This post will give you a practical rule and the underlying mechanics.

Declaring a shape

The simplest object shape, written inline:

function greet(user: { name: string; age: number }): string {
  return `Hello, ${user.name}`;
}

This works, but the shape is anonymous — if a second function takes the same object, you have to type it out again. Both interface and type solve that:

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

type UserAlias = {
  name: string;
  age: number;
};

function greet(user: User): string {
  return `Hello, ${user.name}`;
}

User and UserAlias are interchangeable here. A function expecting one will accept a value typed as the other, provided the shape matches.

Optional properties

A property followed by ? is optional. It may or may not be present:

interface User {
  name: string;
  email?: string;
}

const a: User = { name: "Ada" };                        // ok
const b: User = { name: "Bea", email: "b@example.com" }; // ok

When you read an optional property, its type includes undefined:

function shoutEmail(user: User): string {
  return user.email.toUpperCase();          // Error: 'user.email' is possibly 'undefined'.
}

function shoutEmail2(user: User): string {
  if (user.email === undefined) return "";
  return user.email.toUpperCase();          // ok — narrowed to string
}

This is the same narrowing pattern you saw with string | null in the previous post. The optional ? is just a shortcut for email: string | undefined plus the freedom to omit the key entirely.

Readonly properties

A property prefixed with readonly cannot be reassigned after the object is constructed:

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

const p: Point = { x: 1, y: 2 };
p.x = 10;        // Error: Cannot assign to 'x' because it is a read-only property.

This is a compile-time check, not a runtime guarantee — the field is a regular mutable property in the emitted JavaScript. It is useful nonetheless as a contract: a function that returns a readonly field is signalling that you should not modify it.

Methods

Methods are properties whose value is a function. There are two syntaxes:

interface Counter {
  count: number;
  increment(): void;          // method shorthand
  reset: () => void;          // property of function type
}

Both styles work. The shorthand reads more like a class method; the function-property form is sometimes clearer when you want to emphasise that you can reassign the field. Pick one and stay consistent.

Index signatures

Some objects are used as dictionaries — arbitrary string keys, all mapping to the same kind of value. You describe that with an index signature:

interface ScoreSheet {
  [studentId: string]: number;
}

const scores: ScoreSheet = {
  s001: 92,
  s002: 71,
};

scores.s003 = 85;          // ok
scores.s004 = "high";      // Error: Type 'string' is not assignable to type 'number'.

The parameter name studentId is documentation — only its type (string or number) matters to the compiler. You can mix an index signature with named properties, but every named property must be assignable to the index signature’s value type.

Extending interfaces

An interface can build on another with extends:

interface Animal {
  name: string;
}

interface Dog extends Animal {
  breed: string;
}

const rex: Dog = { name: "Rex", breed: "Labrador" };

Dog has every property of Animal plus its own. You can extend more than one interface at once:

interface Timestamped {
  createdAt: Date;
}

interface AuthoredPost extends Animal, Timestamped {
  title: string;
}

Extension forms the backbone of how interfaces grow in a large codebase — base shapes get refined into specific ones without restating every field.

Intersections — the type-alias equivalent

Type aliases do not have extends. They achieve the same effect with the & operator, called intersection:

type Animal = { name: string };
type Dog = Animal & { breed: string };

const rex: Dog = { name: "Rex", breed: "Labrador" };

A & B means “a value that satisfies both A and B.” For object types this is essentially merging fields. The result is equivalent to the extends version above.

Try it yourself. Declare an interface Vehicle with a wheels: number property. Extend it to Car with make: string and model: string. Then write the same Car using type and &. Hover both and confirm the editor shows the same shape.

Unions — only type aliases

Where type aliases pull ahead is in unions. An interface can only describe object shapes; a type can describe anything:

type Id = number | string;
type Role = "admin" | "editor" | "viewer";
type Result = { ok: true; value: number } | { ok: false; error: string };

Result is a discriminated union — every variant carries a literal ok flag, so the compiler can narrow based on the flag value:

function describe(r: Result): string {
  if (r.ok) {
    return `value is ${r.value}`;      // r is { ok: true; value: number }
  } else {
    return `error: ${r.error}`;        // r is { ok: false; error: string }
  }
}

This pattern is everywhere in production TypeScript. It is how the language expresses what some other languages call “tagged unions” or “sealed classes.” Interfaces cannot represent it directly.

Declaration merging — only interfaces

Two interfaces with the same name in the same scope are merged automatically:

interface Window {
  myCustomProp: string;
}

interface Window {
  anotherProp: number;
}

// Window now has both properties.

You cannot do this with a type alias — declaring type Foo = ... twice is an error. Merging is rarely something you reach for deliberately, but it is essential for extending globally declared types like Window or library-defined interfaces. If a third party ships an interface PluginOptions, you can add fields to it from your own code; you could not if it were a type.

A practical rule for choosing

You will see online debates about which to prefer. Here is a rule that holds up in real projects:

  • Use interface for object shapes that are likely to be extended, especially library or framework code and shapes shared across many files.
  • Use type for everything else: unions, intersections, primitives, tuples, function types, and any shape where extension is unlikely.

A more compressed version: default to interface for objects, switch to type when you need a feature interfaces do not have (unions, mapped types, conditional types, tuples).

Both forms compile to the same emitted JavaScript — none of this affects runtime.

Function types

You can describe a function with either form. The type syntax tends to read more cleanly:

type BinaryOp = (a: number, b: number) => number;

const add: BinaryOp = (a, b) => a + b;
const mul: BinaryOp = (a, b) => a * b;

The interface version uses call signatures:

interface BinaryOp {
  (a: number, b: number): number;
}

Equivalent, but most teams reach for type here. We will cover function types in much more detail in the next post.

Combining shapes in real code

A representative slice of an application’s type layer:

type Role = "admin" | "editor" | "viewer";

interface BaseEntity {
  readonly id: number;
  readonly createdAt: Date;
}

interface User extends BaseEntity {
  name: string;
  email: string | null;
  role: Role;
}

interface Post extends BaseEntity {
  authorId: number;
  title: string;
  body: string;
  publishedAt?: Date;
}

type ApiResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: string };

function handle(result: ApiResult<User>): string {
  if (!result.ok) return `Failed: ${result.error}`;
  return `Loaded ${result.data.name}`;
}

const example: ApiResult<User> = {
  ok: true,
  data: {
    id: 1,
    createdAt: new Date(),
    name: "Ada",
    email: null,
    role: "admin",
  },
};

console.log(handle(example));

Two BaseEntity interfaces sharing fields with extends. Two domain interfaces refining the base. A literal union Role. A generic discriminated union ApiResult<T> — the <T> is a generic parameter, the subject of the final post in this series.

Try it yourself. Add a Comment interface that extends BaseEntity and has postId: number and body: string. Write a function summarise(c: Comment): string and call it. Then change body to readonly and try to mutate it from inside summarise — confirm the compiler stops you.

Recap

You now know:

  • Interface and type both name an object shape; for plain objects they are interchangeable.
  • prop?: T declares an optional property; readonly prop: T declares one that cannot be reassigned.
  • An index signature describes dictionary-like objects with arbitrary keys.
  • Interfaces use extends to refine; type aliases use & (intersection) to combine.
  • Only type aliases can express unions — including the discriminated-union pattern that production code relies on.
  • Only interfaces support declaration merging, which is essential for augmenting global or third-party types.
  • Default rule: interface for objects, type for everything else.

Next steps

You have written a lot of function signatures already. The next post zooms in on functions specifically — parameter types, return types, optional and default parameters, rest parameters, function overloads, and the right way to type callbacks.

→ Next: Typing Functions in TypeScript

Questions or feedback? Email codeloomdevv@gmail.com.