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
- 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 }; // okA 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: 9Your 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-73. 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 404Now 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: $204. 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 .lengthThink 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-onlyPutting 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
- 💡 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 constraint — function 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
| Utility | What it does | Example |
|---|---|---|
| Partial<T> | All properties optional | Partial<User> |
| Required<T> | All properties required | Required<Config> |
| Pick<T, K> | Keep only listed keys | Pick<User, "id"> |
| Omit<T, K> | Drop listed keys | Omit<User, "email"> |
| Record<K, V> | Object from key→value | Record<string, number> |
| Readonly<T> | Freeze all properties | Readonly<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
- ✅ 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 types — Partial, Required, Pick, Omit, Record, Readonly — reshape types without duplication
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
- Previous: Classes and Objects
- Next: Union & Intersection Types — Combine types with A | B and A & B, and narrow them safely
- Quick reference: TypeScript cheat sheet