Skip to main content

    Lesson 6 • Advanced

    Advanced Types 🧬

    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.

    What You'll Learn

    • Combine types with union (A | B) and intersection (A & B)
    • Pin a value to exact options with literal types
    • Narrow safely using typeof, in, and instanceof
    • Model variants cleanly with discriminated unions
    • Write reusable generic <T> functions with constraints
    • Reshape types using Partial, Pick, Omit, Record & more

    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.

    Worked example: typeof and in narrowing

    Run it and match each printed line to the comment that predicts it.

    Try it Yourself »
    JavaScript
    // 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 k
    ...

    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: narrow with typeof

    Fill in the ___ blanks, then check your output against the expected lines.

    Try it Yourself »
    JavaScript
    // 🎯 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-" + _
    ...

    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.

    Worked example: discriminated unions + instanceof

    Each switch branch unlocks only the fields that belong to that shape.

    Try it Yourself »
    JavaScript
    // 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 "⏳ L
    ...

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

    🎯 Your turn: handle a discriminated union

    Switch on the tag, then use the field that belongs to each branch.

    Try it Yourself »
    JavaScript
    // 🎯 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 
    ...

    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: "a@x.com" };
    // 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.

    Worked example: generic Result + narrowing

    Change the data or the error and watch which branch runs.

    Try it Yourself »
    JavaScript
    // 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: " + re
    ...

    Pro Tips

    • 💡 Always add a discriminant. A shared literal tag (status, type, kind) turns a messy union into one a switch can narrow cleanly.
    • 💡 Use Partial<T> for updates, Required<T> for creation — same interface, two different strictness levels, zero duplication.
    • 💡 Let inference do the work. Prefer identity(42) over identity<number>(42); only write the angle brackets when TypeScript can't figure T out.
    • 💡 Constrain, don't widen. T extends { length: number } keeps the exact type and guarantees the property you need.

    Common Errors (and the fix)

    • "TS2339: Property 'toUpperCase' does not exist on type 'string | number'" — you tried to use a string-only method on a union before narrowing. Add a if (typeof x === "string") check first; inside it the method is allowed.
    • Using a field that only exists on one union member — reading state.data outside the case "success" branch fails because TS can't prove it exists yet. Move the access inside the narrowed branch.
    • "TS2322: Type '"north"' is not assignable to type 'Direction'" — you assigned a value outside a literal union. Either add it to the union or fix the value to one of the allowed literals.
    • Confusing | and &A | B means either (fewer guarantees); A & B means both (all properties). Mixing them up gives surprising "missing property" errors.
    • Forgetting the constraintfunction f<T>(x: T) { return x.length; } fails because a bare T might not have .length. Add T extends { length: number }.

    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: format notifications

    Switch on n.type and use the fields unique to each branch.

    Try it Yourself »
    JavaScript
    // 🎯 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)
    //
    // ✅ Ex
    ...

    🎉 Lesson Complete

    • Union A | B = one of; intersection A & B = both at once
    • Literal types pin a value to exact options like "up" | "down"
    • ✅ Narrow with typeof, in, and instanceof before using type-specific methods
    • Discriminated unions + a switch on the tag are the clean way to model variants
    • Generics <T> stay type-safe across types; T extends ... adds a constraint
    • Utility typesPartial, Required, Pick, Omit, Record, Readonly — reshape types without duplication
    • Next lesson: TypeScript with React — build type-safe components and props

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service