Skip to content

Lesson 6 of 1013 min

Type inference and interfaces

Let TypeScript work out most types for you, describe object shapes with interfaces, and narrow unions safely.

In this lesson

  • Rely on inference instead of annotating everything
  • Define and extend interfaces
  • Narrow union types with typeof, in and discriminants

Inference does most of the work#

TypeScript infers a type from the value you assign. Hover over a name in your editor to see what it decided.

inference.ts
let count = 0;1const mode = "dark";2const doubled = [1, 2, 3].map((n) => n * 2);3count = 5; // fine: still a number

let count = 0 is inferred as number.

A const string is inferred as the literal type "dark", because it can never change.

n is inferred as number from the array, and doubled as number[]. No annotations needed.

Where to annotate

Annotate function parameters, exported functions' return types and public data shapes. Let inference handle local variables and callbacks. Your code stays short and the important contracts stay explicit.

Interfaces describe shapes#

An interface names the shape of an object. It reads like documentation and the checker enforces it.

interfaces.ts
interface Course {  id: string;  title: string;  readonly createdAt: Date;1  lessons: number;}interface PaidCourse extends Course {2  price: number;}const course: PaidCourse = {  id: "crs_1",  title: "SQL Basics",  createdAt: new Date("2026-01-15"),  lessons: 8,  price: 150000,};console.log(course.title);

readonly fields can be set when the object is created but not reassigned later.

extends builds a new interface from an existing one, adding fields.

interface or type?

Both describe object shapes and are interchangeable for most code. interface can be extended and merged, and its error messages name it nicely. type can also express unions, tuples and mapped types (type Id = string | number). A common rule: interface for object shapes, type for everything else. Consistency matters more than the choice.

Narrowing unions#

When a value can be one of several types, TypeScript makes you check which one before using type-specific features. The check narrows the type inside the branch.

narrowing.ts
function describe(value: string | number): string {  if (typeof value === "string") {1    return `Text of length ${value.length}`;  }  return `Number rounded to ${value.toFixed(1)}`;2}console.log(describe("hello"), describe(3.14159));

Inside this branch, value is a string, so .length is allowed.

TypeScript knows the string case returned, so here value must be a number.

Discriminated unions#

Give each variant a shared literal field (often kind or type). Checking that field narrows to the exact variant. This pattern models states very clearly.

request-state.ts
type RequestState =  | { kind: "loading" }1  | { kind: "success"; data: string[] }2  | { kind: "error"; message: string };3function render(state: RequestState): string {  switch (state.kind) {    case "loading":      return "Loading…";    case "success":      return `${state.data.length} results`;    case "error":      return `Something went wrong: ${state.message}`;  }}console.log(render({ kind: "success", data: ["a", "b"] }));

No data while loading: the type makes "data before it has loaded" impossible to write.

data exists only on the success variant.

message exists only on the error variant.

ExerciseAdd a state

Add an "empty" variant to RequestState for successful requests with no results, and handle it in render. What does TypeScript do if you add the variant but forget the case?

Show solution

Add | { kind: "empty" } to the union and a case "empty": return "No results";. If you forget the case, the function can now fall through without returning, so TypeScript reports that not all code paths return a string. The type system points you at every place that needs updating.

Recap#

  • Let inference type locals and callbacks; annotate boundaries.
  • Interfaces describe object shapes and can extend each other.
  • Narrow unions with typeof, checks on fields, and discriminants.