webdev.complete
🔵 TypeScript Basics
🛡️TypeScript
Lesson 42 of 117
30 min

Types, Interfaces, Narrowing

Primitives, unions, interfaces, type aliases, narrowing.

TypeScript's job is to describe the shape of your data. Most of that description lives in two concepts: basic types you compose from, and type aliases (or interfaces) you build with. Let's walk the whole inventory and then narrow values down to the specific shape you actually have.

The primitive types

Every TS primitive maps to a JS primitive. Lowercase names. Always.

ts
const name: string = "Ada";
const age: number = 36;
const isAdmin: boolean = true;
const huge: bigint = 9_007_199_254_740_993n;
const id: symbol = Symbol("id");
const nothing: null = null;
const empty: undefined = undefined;
Lowercase, not capitalized
Use string, not String. The capitalized ones (String, Number, Boolean) refer to the rarely-used wrapper objects. Stick with lowercase unless you have a very specific reason not to.

The escape hatches

  • any= "turn off type checking for this value." Use sparingly. Spreads like a virus.
  • unknown= "I don't know what this is, and you have to narrow it before using it." The safe alternative to any.
  • never= "this can't happen." The return type of a function that always throws, or the type of a variable in an unreachable branch.
  • void= "this function returns nothing interesting." Distinct from undefined.

Arrays and tuples

ts
// Two equivalent ways to type an array
const names: string[] = ["Ada", "Grace"];
const ages: Array<number> = [36, 85];

// A tuple is a fixed-length array with a type per position
const point: [number, number] = [10, 20];
const labeled: [string, number] = ["score", 99];

// Tuples can have rest elements
const variadic: [string, ...number[]] = ["scores", 90, 80, 70];

// readonly tuples reject mutation
const origin: readonly [number, number] = [0, 0];
// origin[0] = 5; // Error: Cannot assign to '0' because it is read-only

Object types

You can describe an object shape inline, or give it a name with a type alias or an interface.

ts
// Inline
function greet(user: { name: string; age: number }) {
  return "Hi " + user.name;
}

// Named with type alias
type User = {
  name: string;
  age: number;
};

// Named with interface
interface UserI {
  name: string;
  age: number;
}

Optional and readonly properties

ts
type Profile = {
  readonly id: string;     // can never be reassigned after creation
  name: string;
  bio?: string;            // optional, type is string | undefined
};

const p: Profile = { id: "u_1", name: "Ada" };
// p.id = "u_2";   // Error: Cannot assign, id is readonly
// p.bio is fine to be missing
Optional vs explicit undefined
bio?: string means the key may not exist on the object at all. bio: string | undefined means the key must exist but may hold undefined. Different things. Pick deliberately.

Union and intersection types

A union (|) means "one of these." An intersection (&) means "all of these at once."

ts
// Union: a value is one OR the other
type Status = "pending" | "done" | "error";
type Id = string | number;

// Intersection: combine all properties
type HasName = { name: string };
type HasAge = { age: number };
type Person = HasName & HasAge;  // requires BOTH name and age

const p: Person = { name: "Ada", age: 36 };

Literal types

A type can be a specific value, not just a category. This is how you get enum-like behavior without an enum.

ts
type Direction = "up" | "down" | "left" | "right";

function move(d: Direction) { /* ... */ }

move("up");      // OK
move("upward");  // Error: not assignable to '"up" | "down" | "left" | "right"'

type vs interface

99% of the time, they're interchangeable. Here's the difference that actually matters:

  • interface can be reopened. Declare the same name twice and TS merges them. Useful for extending third-party types.
  • typecannot be reopened. It can model unions, intersections, tuples, and conditional types, things interfaces can't express directly.
ts
// Interface declaration merging
interface Window {
  myCustomFlag?: boolean;  // adds a property to the built-in Window
}

// Type aliases cannot do this
type Foo = { a: number };
// type Foo = { b: string }; // Error: Duplicate identifier

// Only type can model a union
type Result = { ok: true; value: number } | { ok: false; error: string };
The pragmatic rule
Use type by default. Reach for interface when you specifically want declaration merging or when modeling a class-like contract you expect to extend.

Narrowing: telling TS what you already know

A union type says "this could be one of several things." Before you can use it as a specific thing, you have to narrow it. TS understands several patterns:

  • typeof x === "string" for primitives
  • x instanceof Date for class instances
  • "name" in x for checking a property exists
  • A discriminated union with a shared literal property
ts
function describe(x: string | number) {
  if (typeof x === "string") {
    return x.toUpperCase();   // TS knows x is string here
  }
  return x.toFixed(2);         // TS knows x is number here
}

Discriminated unions: the killer pattern

Give every variant in a union a unique literal "tag." TS will use it to narrow with a single switch.

ts
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rect"; w: number; h: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.radius ** 2;
    case "square": return s.side ** 2;
    case "rect":   return s.w * s.h;
  }
}

Try it

Edit the playground below. Try changing the user shape, adding a new Shape variant, or breaking the narrowing on purpose to see what TS complains about.

// A typed user shape with optional and readonly fields
type User = {
  readonly id: string;
  name: string;
  email?: string;
};

const u: User = { id: "u_1", name: "Ada" };
console.log("User:", u);

// A discriminated union
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle":
      return Math.PI * s.radius ** 2;
    case "square":
      return s.side ** 2;
  }
}

console.log("Circle area:", area({ kind: "circle", radius: 5 }));
console.log("Square area:", area({ kind: "square", side: 4 }));

// Narrowing in action
function describe(x: string | number) {
  if (typeof x === "string") {
    return "string of length " + x.length;
  }
  return "number worth " + x.toFixed(2);
}

console.log(describe("hello"));
console.log(describe(3.14159));

Quiz1 / 4

What is the difference between `bio?: string` and `bio: string | undefined`?

Recap

  • Primitives are lowercase: string, number, boolean, and friends.
  • Arrays: T[] or Array<T>. Tuples are fixed-length arrays with a type per index.
  • ? = optional key. readonly = no reassignment. Different things.
  • Union (|) = one of. Intersection (&) = all of. Literal types pin a value.
  • Use type by default. interface for declaration merging.
  • Narrowing turns a wide union into a specific shape inside a branch. Discriminated unions are the cleanest way.