Chapter 7 of 24 · TypeScript for Beginners — Complete Free Tutorial
Union and Intersection Types
Every type you have written in the TypeScript for Beginners series so far describes one exact thing: a string here, a User interface there. Real data rarely arrives in a single flavour — an id may be a string or a number, a setting may be a URL or a port, a value may be missing entirely. This chapter of the 24-chapter series introduces the two operators that model those situations: the union A | B, meaning “either A or B”, and the intersection A & B, meaning “both A and B at once”.
One variable, several types
Write a union with a vertical bar. The parameter below accepts a string and a number and nothing else, and inside the function the compiler knows exactly that:
function printId(id: string | number): void {
console.log(`id: ${id}`);
}
printId('USR-01');
printId(42);
Expected output:
id: USR-01
id: 42
The annotation works in both directions. Callers may pass either branch — printId(true) is rejected with Argument of type 'boolean' is not assignable to parameter of type 'string | number' — and the body is told the value is one of two things, never anything else. Think of | as an OR that the compiler enforces on every assignment, argument and return value.
What you can do with a union
Because a union value might be any of its branches, the compiler only allows operations that make sense on every branch. For string | number the rules look like this:
| Operation | Allowed? | Why |
|---|---|---|
value.toString() | Yes | Both branches inherit it from Object |
console.log(value) | Yes | console.log accepts any value |
value.toFixed(1) | No | Only number has toFixed |
value.length | No | Only string has length |
Reach for a member only one branch owns and the compiler stops you before anything runs: console.log(score.toFixed(1)) inside a function whose parameter is string | number fails with Property 'toFixed' does not exist on type 'string | number'. — followed by Property 'toFixed' does not exist on type 'string', TypeScript naming the branch that has no such member.
Unions of object shapes
Unions really shine with objects. Two shapes usually differ in only a few fields, and a union states “this thing or that thing” honestly instead of inventing a blob where every property is optional:
type Cat = { name: string; purrs: boolean };
type Dog = { name: string; barks: boolean };
function nameOf(pet: Cat | Dog): string {
return pet.name; // shared by both branches
}
console.log(nameOf({ name: 'Nala', purrs: true }));
console.log(nameOf({ name: 'Rex', barks: true }));
Expected output:
Nala
Rex
pet.name compiles because both branches declare it. Ask for pet.barks and you get Property 'barks' does not exist on type 'Cat | Dog' — the Cat branch knows nothing about barking. Chapter 8 shows how to prove which shape you are holding; for now the discipline is simple: on a union, touch only what every member shares. Object shapes themselves were covered in objects and JSON.
Writing union-safe functions
A function that accepts a union must cope with every branch it declares. Three patterns cover almost every case, and the first one is a small taste of narrowing:
function formatId(id: string | number): string {
if (typeof id === 'string') return id.toUpperCase(); // string branch
return `#${id}`; // number branch
}
console.log(formatId('ada'));
console.log(formatId(42));
Expected output:
ADA
#42
- Branch — test with
typeofor a custom guard and handle each case separately. This is what you will do in almost every real function. - Convert — when the branches behave similarly, normalise first with
String(value)orNumber(value). - Defer — return the union untouched and let the caller decide; that is what libraries do when they cannot know your intent.
Intersection types
Where a union means “either”, an intersection means “both”. A & B produces a type carrying every member of A and every member of B, which is the standard way to grow a reusable base object into a richer one:
type User = { id: string; name: string };
type Timestamps = { createdAt: string; updatedAt: string };
type AuditUser = User & Timestamps;
const ada: AuditUser = {
id: 'u_1',
name: 'Ada',
createdAt: '2026-01-01',
updatedAt: '2026-01-02',
};
console.log(`${ada.name} (${ada.id}) since ${ada.createdAt}`);
Expected output:
Ada (u_1) since 2026-01-01
Two details matter. First, an intersection is purely a compile-time description — nothing is merged at runtime; the value is one ordinary object carrying all the fields. Second, overlapping members must be compatible: a property declared string on one side and number on the other collapses to string & number, which is never — a type no value can satisfy, so the object becomes impossible to fill in.
A preview: unions of literals
The branches of a union do not have to be whole types. A literal type describes one exact value, so a union of literals describes a fixed menu of values:
type Direction = 'up' | 'down' | 'left' | 'right';
let where: Direction = 'up'; // ✅ one of the four
where = 'sideways'; // ❌ fails to compile
Expected output: no output — the second line fails with Type '"sideways"' is not assignable to type 'Direction'.. This is not a string that happens to hold one of four words — it is four specific values and no others, checked before the code runs. Chapter 9 covers literal types, as const and enums in full.
Worked example: a config value
Configuration is where unions and intersections meet in real code. A setting may arrive as a ready-made URL, a bare port or a full object — and the full object is an intersection of the basic object plus an extra flag:
type ServerConfig = { host: string; port: number };
type SecureConfig = ServerConfig & { https: boolean };
function describe(value: string | number | ServerConfig): string {
if (typeof value === 'string') return `url: ${value}`;
if (typeof value === 'number') return `port: ${value}`;
return `host: ${value.host}, port: ${value.port}`;
}
const dev: SecureConfig = { host: 'localhost', port: 5173, https: false };
console.log(describe('https://scriptly.dev'));
console.log(describe(8080));
console.log(describe(dev));
Expected output:
url: https://scriptly.dev
port: 8080
host: localhost, port: 5173
Read the third call carefully: dev is a SecureConfig, yet it is accepted where a ServerConfig is expected, because an intersection contains everything the base type asks for. Notice too that https is never mentioned inside describe — the function only needs the common core, so it stays valid for plain configs as well.
Common mistakes
- Reaching for a member only one branch has —
value.toFixed()on a union fails with TS2339; narrow the value first (chapter 8) or convert it. - Assuming extra properties are free — an object literal typed directly into a union position is still checked:
{ name: 'x', purrs: true, barkd: true }fails with'barkd' does not exist in type 'Cat | Dog'. - Mixing up
|and&—A | Bgives you either and only the shared members;A & Bgives you both and every member. - Building mega-unions —
string | number | boolean | objectaccepts almost everything and tells the compiler almost nothing; prefer the precise set of shapes you actually handle. - Forgetting
nullandundefined— under strict mode a value that might be absent must say so:string | undefined, never a barestring.
Practice checklist
- Write
formatIdyourself, call it with a boolean, and read the error TypeScript returns. - Create a union of three object shapes and list by hand the properties all three share.
- Turn one of your own interfaces into
Base & Extraand confirm existing code still compiles. - Deliberately write
string & numberand observe how assignment fails. - Rewrite a set of fixed string values from a previous project as a union of literals.
What's next?
A union tells you a value could be one of several things — the next chapter teaches TypeScript how to find out which: Narrowing and Type Guards.
Frequently Asked Questions
TypeScript is a strict superset of JavaScript that adds a static type system. You annotate the promises your data makes, the compiler checks every one before the code runs, then erases the types and emits ordinary JavaScript. You catch wrong arguments, typoed properties and forgotten null checks while you are still typing, and refactoring plus editor autocomplete both improve sharply.
You should. This series assumes you can already read and write JavaScript: functions, arrays and objects, closures, modules and async/await. TypeScript audits that knowledge rather than replacing it, and types cannot rescue code you do not understand. If those fundamentals feel shaky, work through the JavaScript for Beginners series on this site first, and this course will go much faster.
No. TypeScript is compiled, not interpreted: tsc parses your file, type-checks it, then erases every annotation and writes ordinary JavaScript. The browser and Node.js only ever run that emitted .js, so runtime behaviour, performance and debugging are unchanged. It also never validates API data at runtime, so the series teaches you to distrust every boundary that leaves the compiler.
If you already know JavaScript, TypeScript is a modest step rather than a new language: the syntax is the same plus a colon. Most beginners read their first red squiggle within days and feel comfortable in two to four weeks of regular practice. The curve steepens later around generics and advanced types, which is where this 24-chapter series deliberately spends its second half.
Both name the same object shapes, so either works for a simple contract. The differences decide the rest: only interfaces merge when declared twice, while only type aliases can express unions, primitives, tuples and mapped types. The series default is interface for props and exported shapes other files consume, and type for everything else. Pick one style per project and stay consistent.
strict: true is a bundle of safety switches, not a single behaviour. It turns on noImplicitAny, so every parameter must be inferrable, strictNullChecks, so null and undefined are their own types you must narrow, strictFunctionTypes, noImplicitThis, strictPropertyInitialization, and useUnknownInCatchVariables, which types catch values as unknown. Every example in this series compiles with it on.
any accepts anything and switches the compiler off, so one any spreads through inference into everything derived from it and you are back to dynamic typing with a .ts extension. unknown accepts anything too, but forbids property access until you narrow it with a typeof or instanceof check. The series pushes you toward unknown because it keeps the check you installed TypeScript to get.
Yes to both. Vite understands TypeScript natively: scaffold the vanilla-ts or react-ts template and a tsconfig.json is waiting for you, with moduleResolution set for the bundler. JSX belongs in .tsx files, props get an interface, and hooks infer or accept explicit type arguments. The series covers typed React in depth, from props, state and events to refs, context and fetch effects.
Enums still compile and work fine in TypeScript 5.x, but they are no longer the default recommendation. A union of string literals such as "light" | "dark" needs no runtime object, matches JSON directly and plays well with single-file transpilers like Vite's esbuild. Keep enum for existing codebases that already use it, avoid const enum, and prefer literal unions in new code.
Adopt it incrementally instead of rewriting. Install typescript, add a tsconfig.json with allowJs and noEmit so .js and .ts coexist while your bundler keeps building, then check individual files with a // @ts-check comment. Rename leaf modules to .ts one at a time, fix implicit any errors first, and gate every pull request with tsc --noEmit so untyped code cannot sneak back in.