jsguides

TypeScript types and interfaces: a complete tutorial

Now that you have learned the basics of TypeScript, it is time to dive into two of the most important concepts in the language: types and interfaces. Understanding TypeScript types and interfaces is essential for writing maintainable, type-safe code. Each section pairs a short explanation with a runnable example so you can paste snippets into a TypeScript playground and watch the inferred types update as you edit.

By the end you should be comfortable choosing between a type and an interface for any common shape: function signatures, configuration objects, discriminated unions, and library-facing API surfaces. We’ll close with a checklist you can apply when reviewing pull requests, plus pointers to the JavaScript symbols guide and the JavaScript generators guide for related advanced patterns.

What you will learn

This tutorial covers type aliases, interfaces, unions, intersections, extension, generics, index signatures, and the decision framework for choosing one over the other. Each concept includes a runnable code example. By the end, you will be able to define expressive types for function parameters, object shapes, and generic data structures, and you will know when an interface communicates intent better than a type alias.

Type aliases

A type alias creates a new name for an existing type. They are useful for creating descriptive names for complex types.

Creating type aliases

Use the type keyword to give a name to any existing type: primitives, objects, functions, or unions. The alias lets you reuse the definition without repeating the full type annotation:

type ID = string;
type Point = { x: number; y: number };
type Callback = (result: string) => void;

Once defined, you can use the aliases in function signatures, variable declarations, and return types. The compiler checks that every use site provides a value matching the alias, catching mismatches at build time rather than runtime:

type UserID = string;

function getUserById(id: UserID): UserID {
  return id;
}

const userId: UserID = "abc123";

Type aliases also compose naturally with union types. A union restricts a value to one of several literal options, which is especially useful for state machines and API status fields:

Union types with type aliases

type Status = "pending" | "active" | "completed";

function setStatus(status: Status): void {
  console.log(`Setting status to: ${status}`);
}

setStatus("active");   // OK
setStatus("deleted");  // Error: Type '"deleted"' is not assignable to type 'Status'

Union types narrow a value to one shape. Intersection types do the opposite: they merge two or more types into one, requiring a value to satisfy all of them simultaneously. This is how you compose types from smaller, reusable pieces:

type Named = { name: string };
type Aged = { age: number };

type Person = Named & Aged;

const person: Person = {
  name: "Alice",
  age: 30
};

Type aliases cover primitives, unions, and intersections well. For object shapes that may be extended or implemented by classes, TypeScript offers interfaces. Unlike types, interfaces are open: you can add fields to them later through declaration merging, and they produce clearer error messages when shapes do not match. The next section explores what interfaces can do that type aliases cannot:

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

const user: User = {
  id: "1",
  name: "Alice",
  email: "alice@example.com"
};

Not every field needs to be required. Adding a ? after a property name tells TypeScript that the field may be absent, which is common for optional form fields, configuration overrides, and API response shapes where the server may omit values:

interface User {
  id: string;
  name: string;
  email?: string;  // Optional
  age?: number;   // Optional
}

const user1: User = {
  id: "1",
  name: "Alice"
};

const user2: User = {
  id: "2",
  name: "Bob",
  email: "bob@example.com",
  age: 25
};

Optional fields let callers omit data. The readonly modifier gives you the opposite guarantee: once a value is assigned, TypeScript prevents any further writes. This protects configuration objects, API keys, and identity fields from accidental mutation deep inside your codebase:

interface Config {
  readonly apiKey: string;
  readonly baseUrl: string;
}

const config: Config = {
  apiKey: "secret123",
  baseUrl: "https://api.example.com"
};

config.apiKey = "newkey";  // Error: Cannot assign to 'apiKey' because it is a read-only property

At this point you have seen both type aliases and interfaces in action. The next section compares them side by side so you know which tool fits each situation. The short answer: interfaces for public API contracts that may grow over time, type aliases for unions, tuples, and function signatures where the exact shape is the point.

// Type aliases excel at primitives, unions, and function types
type StringOrNumber = string | number;
type LogFunction = (message: string) => void;
interface Animal {
  name: string;
}

interface Animal {
  age: number;
}

// Animal now has both name and age
const animal: Animal = {
  name: "Dog",
  age: 3
};

Extending interfaces

Interfaces can extend other interfaces to inherit their properties. A single extends clause creates a parent-child relationship where the child receives every field from the parent. That is enough for simple cases. When a shape pulls from multiple sources, TypeScript lets you list several interfaces in the extends clause:

Basic extension

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

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

const employee: Employee = {
  name: "Alice",
  age: 30,
  employeeId: "E001",
  department: "Engineering"
};

Multiple extension

interface Named {
  name: string;
}

interface Dated {
  createdAt: Date;
  updatedAt: Date;
}

interface Auditable extends Named, Dated {
  id: string;
}

const record: Auditable = {
  id: "1",
  name: "Document",
  createdAt: new Date(),
  updatedAt: new Date()
};

Type aliases achieve the same composition through intersection types rather than extends. The result is identical at runtime, but the syntax reads differently — intersections use & and combine the full shapes inline:

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

type Employee = Person & {
  employeeId: string;
  department: string;
};

const employee: Employee = {
  name: "Alice",
  age: 30,
  employeeId: "E001",
  department: "Engineering"
};

Interfaces can also describe behaviour, not just data. When you define a method signature inside an interface, any object or class that claims to satisfy it must provide an implementation. This is the foundation of TypeScript’s structural type system. The compiler checks that the shape matches, regardless of how the object was created:

interface Calculator {
  add(a: number, b: number): number;
  subtract(a: number, b: number): number;
}

const calculator: Calculator = {
  add(a, b) {
    return a + b;
  },
  subtract(a, b) {
    return a - b;
  }
};

console.log(calculator.add(5, 3));      // 8
console.log(calculator.subtract(10, 4)); // 6

Marking a method with ? makes it optional; callers can omit it, and the object satisfies the interface without providing an implementation. This is common for logger interfaces where debug is useful during development but unnecessary in production:

interface Logger {
  info(message: string): void;
  warn(message: string): void;
  error(message: string): void;
  debug?(message: string): void;  // Optional
}

const logger: Logger = {
  info(msg) { console.log("INFO:", msg); },
  warn(msg) { console.log("WARN:", msg); },
  error(msg) { console.log("ERROR:", msg); }
  // debug is not implemented but allowed
};

So far every property has a fixed name. Sometimes the keys are not known ahead of time: dictionaries, caches, and configuration maps all use dynamic keys. An index signature lets you declare that any string key maps to a value of a given type, while still allowing the compiler to catch mismatched assignments:

interface StringDictionary {
  [key: string]: string;
}

const dict: StringDictionary = {
  hello: "world",
  foo: "bar",
  key: "value"
};

A pure index signature treats every key the same way. In practice, many objects have a few fixed fields plus arbitrary extra keys. TypeScript supports mixing the two: declare the known fields first, then the index signature, and the value type must be broad enough to cover both:

interface User {
  id: string;
  name: string;
  [key: string]: string | number;
}

const user: User = {
  id: "1",
  name: "Alice",
  email: "alice@example.com",
  age: 30,
  location: "London"
};

Index signatures make the value type flexible. Generics take that flexibility further by parameterizing the entire interface on a type variable. This lets you write a single Container definition that works with strings, numbers, or custom types. The compiler substitutes the concrete type at each use site while still enforcing consistency across the object:

interface Container<T> {
  value: T;
  getValue(): T;
}

const stringContainer: Container<string> = {
  value: "hello",
  getValue() {
    return this.value;
  }
};

const numberContainer: Container<number> = {
  value: 42,
  getValue() {
    return this.value;
  }
};

A single type parameter is the most common case. When you need to relate two different types: a key and its corresponding value — you can parameterize on both dimensions:

interface Pair<K, V> {
  key: K;
  value: V;
}

const pair: Pair<string, number> = {
  key: "age",
  value: 30
};

The features covered so far: unions, intersections, extension, index signatures, and generics. These are building blocks. The example below combines them into a realistic user management model. Start with the type definitions: a discriminated union for status values, a base interface for timestamps, and derived interfaces that add domain-specific fields:

// Base types
type UserStatus = "active" | "inactive" | "suspended";

// Base interface
interface BaseEntity {
  id: string;
  createdAt: Date;
  updatedAt: Date;
}

// Extended interface
interface User extends BaseEntity {
  username: string;
  email: string;
  status: UserStatus;
  profile?: UserProfile;
}

// Nested interface
interface UserProfile {
  firstName: string;
  lastName: string;
  avatar?: string;
  preferences: UserPreferences;
}

interface UserPreferences {
  theme: "light" | "dark";
  notifications: boolean;
}

With the types declared, creating a user is a matter of providing a value that matches the shape. The compiler checks every field and nested object, and it will reject missing required fields or mis-typed values:

// Create a user
const user: User = {
  id: "1",
  username: "alice",
  email: "alice@example.com",
  status: "active",
  createdAt: new Date(),
  updatedAt: new Date(),
  profile: {
    firstName: "Alice",
    lastName: "Smith",
    preferences: {
      theme: "dark",
      notifications: true
    }
  }
};

Finally, a pure function that takes a user and a new status returns an updated copy. This pattern of immutable updates using the spread operator — keeps the original object intact and is a common approach in Redux reducers and React state management:

// Function using our types
function updateUserStatus(user: User, status: UserStatus): User {
  return {
    ...user,
    status,
    updatedAt: new Date()
  };
}

const updatedUser = updateUserStatus(user, "inactive");
console.log(updatedUser.status);  // "inactive"

Let the shape tell the story

When you choose between a type alias and an interface, the best choice is often the one that reads most naturally to the person coming after you. If the shape is a plain object contract, an interface can feel direct and familiar. If you need unions, tuples, or transformations, a type alias often speaks more clearly. The name you pick is less important than whether it helps the reader understand the data at a glance.

It also helps to keep related types near one another. A base type, a derived type, and a few utility transformations are easier to read when they are grouped together instead of scattered across files. That small bit of organization makes refactors calmer because you can see how the shapes fit before you change them.

Conclusion

Type aliases and interfaces are two of the most practical tools in TypeScript. The key insight is not which one is better. It is that each solves a different communication problem. Use the tool that makes the intent clearest to the next developer who reads your code.

Where to go next

Type aliases handle primitives, unions, and function signatures. Interfaces describe object contracts that may be extended. The next tutorial in this series covers TypeScript generics, which let you write components that work with any data type while maintaining type safety. For type transformations, the utility types and mapped types tutorials cover Partial, Required, Pick, Omit, and conditional type patterns.

See Also