Advanced Types

Reviewed & published by Brayan K

By the end of this lesson you'll be able to combine types with unions and intersections, lock values to exact literals, safely narrow a value to one type before using it, write reusable generic functions, and reshape any type with TypeScript's built-in utility types — the toolkit behind every well-typed codebase.

Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

1. Unions, Intersections & Literal Types

A union type, written A | B, means a value is one of several types — like an id that could be a string or a number. A literal type goes further and pins a value to an exact value, so "up" | "down" allows only those two strings. An intersection, written A & B, does the opposite of a union: the value must have everything from both types combined.

This is TypeScript-only syntax (it disappears when the code runs), so read it here rather than running it:

// UNION (A | B): the value is ONE of these types.
type Id = string | number;          // an id can be text OR a number
let userId: Id = "abc123";          // ok
userId = 42;                        // also ok
// userId = true;                   // ❌ boolean is not in the union

// LITERAL types: the value must be one EXACT value.
type Direction = "up" | "down" | "left" | "right";
let move: Direction = "up";         // ok — one of the four allowed strings
// move = "north";                  // ❌ "north" is not allowed

// INTERSECTION (A & B): the value has EVERYTHING from both types.
type Name   = { name: string };
type Age    = { age: number };
type Person = Name & Age;           // must have BOTH name AND age
const p: Person = { name: "Ada", age: 36 };   // ok

A handy way to remember it: union uses the OR bar | and gives you fewer guarantees (you don't know which type yet); intersection uses the AND symbol & and gives you more (you get all properties at once).

2. Type Narrowing & Guards

Once a value is a union, you can't immediately call methods that only exist on one member — TypeScript will stop you. Narrowing is the act of proving, with a runtime check, which type you actually have. Three everyday guards do most of the work: typeof for primitives, in for "does this object have that property", and instanceof for classes. Read this worked example, run it, then you'll write your own.

// In TypeScript a value can be a UNION like (string | number).
// Before you use type-specific methods, you must NARROW it to one type.
// These checks are normal JavaScript — TS just tracks the result for you.

function describe(value) {
  // typeof narrows primitives: "string", "number", "boolean"
  if (typeof value === "string") {
    // Here TS knows value is a string -> .toUpperCase() is safe
    return "text: " + value.toUpperCase();
  }
  if (typeof value === "number") {
    // Here TS knows value is a number -> .toFixed() is safe
    return "number: " + value.toFixed(2);
  }
  return "other: " + value;
}

console.log(describe("hello"));   // text: HELLO
console.log(describe(3.14159));   // number: 3.14
console.log(describe(true));      // other: true

// 'in' narrows objects by checking a property exists.
function area(shape) {
  if ("radius" in shape) {
    return "circle area: " + (3.14 * shape.radius * shape.radius);
  }
  return "square area: " + (shape.side * shape.side);
}
console.log(area({ radius: 2 }));  // circle area: 12.56
console.log(area({ side: 3 }));    // square area: 9

Your turn. The function below already has its structure — just fill in the two blanks marked ___ using the hints, then run it and check the output.

// 🎯 YOUR TURN — fill in the blanks marked ___ then press "Run".
// formatId accepts a (string | number) and returns a tidy label.

function formatId(id) {
  // 1) Narrow to STRING: check the typeof id
  if (typeof id === ___) {        // 👉 the word "string" in quotes
    // strings get an "#" prefix and uppercase
    return "#" + id.toUpperCase();
  }

  // 2) Otherwise it's a number — pad it to look like an order id
  // 👉 use id.toString() to turn the number into text
  return "ORDER-" + ___;          // 👉 id.toString()
}

console.log(formatId("ab12"));    // 👈 should print: #AB12
console.log(formatId(7));         // 👈 should print: ORDER-7

// ✅ Expected output:
//    #AB12
//    ORDER-7

3. Discriminated Unions (the pro pattern)

The cleanest way to model "this is one of several shapes" is a discriminated union: every member shares one common property — the discriminant, usually a literal like status: "loading". When you check that property in a switch, TypeScript narrows to the exact shape and lets you safely reach the fields unique to that branch. This single pattern replaces a pile of optional fields and if checks.

// DISCRIMINATED UNION — the cleanest way to model "one of several shapes".
// Each shape shares a common literal tag (here: "status").
// Checking that tag narrows to the exact shape, with its own fields.

// In TS these are the types:
//   type State =
//     | { status: "loading" }
//     | { status: "success"; data: string[] }
//     | { status: "error";   message: string };

function render(state) {
  switch (state.status) {          // the discriminant
    case "loading":
      return "⏳ Loading...";
    case "success":
      // TS knows 'data' exists ONLY in this branch
      return "✅ " + state.data.join(", ");
    case "error":
      // TS knows 'message' exists ONLY in this branch
      return "❌ " + state.message;
  }
}

console.log(render({ status: "loading" }));
console.log(render({ status: "success", data: ["Ada", "Bob"] }));
console.log(render({ status: "error", message: "Network timeout" }));

// instanceof narrows by CLASS — useful for error handling.
class ApiError { constructor(code) { this.code = code; } }

function handle(err) {
  if (err instanceof ApiError) {
    return "API failed with code " + err.code;
  }
  return "Unknown error";
}
console.log(handle(new ApiError(404)));   // API failed with code 404

Now you try. Finish the payment handler by naming the discriminant property and returning the cash message. Fill in the two blanks:

// 🎯 YOUR TURN — finish the discriminated union handler.
// Each payment object has a "method" tag: "card" or "cash".
//
// TS types (for reference):
//   type Payment =
//     | { method: "card"; last4: string }
//     | { method: "cash"; amount: number };

function describePayment(payment) {
  // 1) Switch on the discriminant property
  switch (payment.___) {          // 👉 the tag property name: method
    case "card":
      // 'last4' only exists on the card shape
      return "Card ending " + payment.last4;
    case "cash":
      // 2) Return a cash message using payment.amount
      return "Cash: $" + ___;     // 👉 payment.amount
  }
}

console.log(describePayment({ method: "card", last4: "4242" }));
console.log(describePayment({ method: "cash", amount: 20 }));

// ✅ Expected output:
//    Card ending 4242
//    Cash: $20

4. Generics & Constraints

A generic is a type variable — written <T> — that lets one function work with any type while keeping it type-safe. You don't lose information: if you pass a string in, you get a string back, not a vague "any". A constraint, written T extends ..., narrows what T is allowed to be, so you can safely use the properties the constraint promises. This is TypeScript-only syntax, so read it here:

// A GENERIC function works for ANY type T — you pick T when you call it.
function identity<T>(value: T): T {
  return value;                     // returns exactly the type you passed in
}
const a = identity<string>("hi");   // a is string
const b = identity(42);             // T inferred as number — no <> needed

// A CONSTRAINT (T extends ...) limits what T can be.
// Here T must at least have a .length, so .length is safe to read.
function logLength<T extends { length: number }>(item: T): T {
  console.log(item.length);
  return item;
}
logLength("hello");                 // ok — strings have .length (5)
logLength([1, 2, 3]);               // ok — arrays have .length (3)
// logLength(42);                   // ❌ number has no .length

Think of <T> as a blank you fill in at the call site. Most of the time TypeScript infers it for you from the argument, so you rarely type the angle brackets yourself.

5. The Essential Utility Types

TypeScript ships with built-in utility types that reshape an existing type so you never repeat yourself. The six you'll reach for daily: Partial (all optional), Required (all required), Pick (keep some), Omit (drop some), Record (build a key→value map), and Readonly (freeze it). Define your type once, then derive the variations:

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

// Partial<T> — every property becomes optional. Perfect for updates.
type UserUpdate = Partial<User>;
// = { id?: number; name?: string; email?: string }

// Required<T> — the opposite: every optional property becomes required.
type FullUser = Required<UserUpdate>;     // back to all-required

// Pick<T, Keys> — keep ONLY the listed properties.
type UserPreview = Pick<User, "id" | "name">;
// = { id: number; name: string }

// Omit<T, Keys> — keep everything EXCEPT the listed properties.
type PublicUser = Omit<User, "email">;
// = { id: number; name: string }

// Record<Keys, Value> — build an object type from a key set + value type.
type Roles = Record<"admin" | "guest", boolean>;
// = { admin: boolean; guest: boolean }

// Readonly<T> — no property can be reassigned after creation.
type FrozenUser = Readonly<User>;
const u: FrozenUser = { id: 1, name: "Ada", email: "[email protected]" };
// u.name = "Bob";   // ❌ Cannot assign to 'name' — it is read-only

Putting It Together: a Result helper

This small program combines a generic <T> with a discriminated union and narrowing — exactly the pattern real apps use to handle "this might have succeeded or failed". Read it line by line; you understand every piece now.

// A small "API result" helper that combines this whole lesson:
// a discriminated union + a generic <T> for the success data.
//
// TS type:
//   type Result<T> =
//     | { ok: true;  value: T }
//     | { ok: false; error: string };

// A generic function: works for a Result of ANY data type T.
function unwrap(result, fallback) {
  if (result.ok) {            // narrows to the success shape
    return result.value;     // TS knows .value is type T here
  }
  console.log("Handled error: " + result.error);  // narrows to failure
  return fallback;           // safe default when it failed
}

const good = { ok: true, value: ["Ada", "Bob"] };
const bad  = { ok: false, error: "404 Not Found" };

console.log(unwrap(good, []));   // [ 'Ada', 'Bob' ]
console.log(unwrap(bad, []));    // logs the error, then prints  []

Pro Tips

Common Errors (and the fix)

Quick Reference: Utility Types

UtilityWhat it doesExample
Partial<T>All properties optionalPartial<User>
Required<T>All properties requiredRequired<Config>
Pick<T, K>Keep only listed keysPick<User, "id">
Omit<T, K>Drop listed keysOmit<User, "email">
Record<K, V>Object from key→valueRecord<string, number>
Readonly<T>Freeze all propertiesReadonly<User>

Frequently Asked Questions

Q: What's the real difference between | and &?

A | B (union) means the value is one of A or B, so you can only use what they share until you narrow. A & B (intersection) means the value is both at once, so it has every property from A and B combined.

Q: Why does TypeScript say a property "does not exist" on my union?

Because that property only exists on some members of the union, and TS can't yet prove you have the right one. Narrow first with typeof, in, instanceof, or a discriminant check, then the property becomes available inside that branch.

Q: When should I reach for a generic instead of any?

Almost always. any switches off type checking entirely; a generic <T> keeps the relationship between input and output, so you stay type-safe and get autocomplete. Use a generic whenever a function should work for many types but preserve which one it got.

Q: Do utility types create new objects at runtime?

No. They are purely compile-time tools that reshape types, then vanish when TypeScript compiles to JavaScript. Partial<User> produces zero runtime code — it just changes what the compiler allows.

Mini-Challenge: Notification Formatter

No blanks this time — just a brief and an outline to keep you on track. Write the describeNotification function yourself, switching on the discriminant, then run it and check your output against the example in the comments.

// 🎯 MINI-CHALLENGE: a notification formatter
//
// You receive notifications shaped as a discriminated union on "type":
//   type Notification =
//     | { type: "message"; from: string; text: string }
//     | { type: "alert";   level: "low" | "high"; text: string };
//
// 1. Write describeNotification(n) that switches on n.type
// 2. For "message": return  "💬 {from}: {text}"
// 3. For "alert":   return  "🚨 [{level}] {text}"
// 4. Test it with both shapes below (already provided)
//
// ✅ Expected output:
//    💬 Ada: hi there
//    🚨 [high] disk almost full

const a = { type: "message", from: "Ada", text: "hi there" };
const b = { type: "alert", level: "high", text: "disk almost full" };

// your code here — then:
// console.log(describeNotification(a));
// console.log(describeNotification(b));

🎉 Lesson Complete

Practice quiz

What does a union type A | B mean?

  • The value has everything from both A and B
  • The value is neither A nor B
  • The value is one of A or B
  • The value is converted from A to B

Answer: The value is one of A or B. A union A | B means the value is one of the listed types - you only know which after narrowing.

What does an intersection type A & B require?

  • The value has everything from both A and B combined
  • The value is either A or B
  • The value matches only the shared properties
  • The value is the smaller of A and B

Answer: The value has everything from both A and B combined. An intersection A & B means the value has all properties of A AND B at once.

What does the literal union type "up" | "down" allow?

  • Any string
  • Any two-character string
  • Numbers 1 and 2
  • Only the exact strings "up" or "down"

Answer: Only the exact strings "up" or "down". Literal types pin a value to exact values, so only "up" or "down" are accepted - "left" would be an error.

Which guard is used to narrow primitives like string and number?

  • instanceof
  • typeof
  • in
  • extends

Answer: typeof. typeof narrows primitives ("string", "number", "boolean"); in checks for a property and instanceof narrows by class.

Which operator narrows an object by checking whether a property exists?

  • in
  • typeof
  • instanceof
  • keyof

Answer: in. The in operator checks a property exists on an object, narrowing the union to the member that has it.

What is the discriminant in a discriminated union?

  • A randomly chosen property
  • The longest property name
  • A shared literal-typed property like status or kind
  • An optional field unique to one member

Answer: A shared literal-typed property like status or kind. Every member shares one common literal property (the discriminant); checking it in a switch narrows to the exact shape.

What does a constraint like <T extends { length: number }> guarantee?

  • T must be a number
  • T must have a length property, so reading it is safe
  • T can only be a string
  • T is converted to an array

Answer: T must have a length property, so reading it is safe. The constraint limits T to types with a length property, so .length is safe to read inside the function.

Which utility type makes every property of a type optional?

  • Required<T>
  • Pick<T, K>
  • Readonly<T>
  • Partial<T>

Answer: Partial<T>. Partial<T> turns every property optional - perfect for update payloads. Required<T> does the opposite.

What does Omit<User, "email"> produce?

  • Only the email property
  • User with every property except email
  • User with email made optional
  • A union of User and email

Answer: User with every property except email. Omit<T, Keys> keeps everything except the listed keys, so Omit<User, "email"> is User without email.

Do utility types like Partial<User> produce runtime code?

  • Yes, they create new objects at runtime
  • Only when used with classes
  • No, they are compile-time only and produce zero runtime code
  • Only in strict mode

Answer: No, they are compile-time only and produce zero runtime code. Utility types reshape types at compile time and vanish when TypeScript compiles to JavaScript - no runtime output.

Continue this course