Skip to Content
BlogCodeUnderstanding TypeScriptunderstanding typescript 2

Intersection types

Intersection types let you combine multiple types into one using &.

  • For object types, an intersection generally means “must satisfy all properties from both sides.”
  • For union types, an intersection means “the overlap between the unions” (sometimes that overlap is never if there’s no common member).
type Admin = { name: string; privileges: string[] } type Employee = { name: string; startDate: Date; } type ElevatedEmployee = Admin & Employee // equivalent of interface Elevatedemployee extends Employee, Admin {} const e1: ElevatedEmployee = { name: 'Max', privileges: ['create-server'], startDate: new Date() } type Combinable = string | number; type Numeric = number | boolean type Universal = combinable & Numeric // universal ends up being number.

Key takeaways:

  • ElevatedEmployee must have name, privileges, and startDate.
  • (string | number) & (number | boolean) resolves to number because that’s the only member present in both unions.
  • If there’s no overlap, the intersection becomes never.

Type guards

A type guard is any check that helps TypeScript narrow a union type to something more specific so you can safely use type-specific operations.

typeof type guard (primitives)

type Combinable = string | number; function add(a: Combinable, b: Combinable){ if(typeof a === 'string' || typeof b === 'string') { // this is called a typeguard, using typeof. return a.toString() + b.toString(); } return a+b; }

Here:

  • typeof ... === 'string' narrows that value to string.
  • Otherwise, both are treated as number, so a + b is numeric addition.

in type guard (object shapes)

When you have a union of object types, typeof won’t help. Instead, use property existence checks with in.

type Admin = { name: string; privileges: string[] } type Employee = { name: string; startDate: Date; } type ElevatedEmployee = Admin & Employee type UnknownEmployee = Employee | Admin; function printEmployeeInformation(emp : UnknownEmployee){ console.log(emp.name) // console.log(emp.privileges) // errors because ooptional, only admin has it. // therefore correction: if('privileges' in emp){// can be used with interfaces. console.log(emp.privileges); } if('startDate' in emp){ console.log(emp.startDate); } } printEmployeeInformatioN({name:'anu', startDate: new Date()});

Why this matters:

  • UnknownEmployee could be either type, so accessing privileges directly is unsafe.
  • 'privileges' in emp narrows emp to Admin (or at least to “something that has privileges”).

instanceof type guard (classes)

For class-based unions, instanceof is usually the cleanest runtime-safe narrowing.

class Car{ drive(){ console.log('Driving') } } class Truck { drive() { console.log('Driving truck') } loadCargo(amount){ console.log(`loading cargo... ${amount}`) } } const v1 = new Car(); const v2 = new Truck(); function useVehicle(vehicle: Vehicle){ vehicle.drive(); //if( 'loadCargo' in vehicle){ // another way of doing this: if(vehicle instanceof Truck){ vehicle.loadCargo(1000); } } type Vehicle = Car | Truck

Notes:

  • instanceof works because Truck exists at runtime (it’s a real constructor function).
  • in is also valid here, but instanceof is often clearer for class hierarchies.

Discriminated unions

A discriminated union is a pattern that makes type narrowing easy and scalable by adding a shared “tag” property with literal values (often type).

interface Bird { flyingSpeed: number; type: 'bird'; } interface Horse { runningSpeed: number; type: 'horse'; } type Animal = Bird | Horse; /* The above code is discriminated union, making it easier to discriminate types by using literal properties, or other properties that we know the value of. */ function moveAnimal(animal: Animal){ let speed; switch(animal.type){ case 'bird': speed = animal.flyingSpeed;break; case 'horse':speed = animal.runningSpeed;break; } console.log('Moving at speed: ' + speed) } moveAnimal({type:'bird', flyingSpeed: 10});

Why it’s great:

  • Adding more variants (Fish, Snake, etc.) scales better than piling on in checks.
  • switch(animal.type) becomes a central place to handle each variant.
  • You can also do “exhaustiveness checks” (ensuring every case is handled) as your union grows.

Type casting (type assertions)

Type assertions are for when TypeScript can’t know a specific type, but you (as the author) know it.

Important: type assertions do not change runtime behavior—no conversion happens. They only affect the type checker.

//const paragraph = document.getElementById('user-input')! // const userInputElement = document.getElementById('user-input')! as HTMLInputElement// alternative since react also uses anglebrackets. // one way of doing this.<HTMLInputElement>document.getElementById('user-input')!; html element is the type, since ts can't know the specific type. userInputElement.value = 'Hi there!'; //throws an error without typecasting since htmlElement doesn't have a property value. //alternative to exclamationmark: (userInputElement ad HTMLInputElement).value = 'Hi there!'

What’s going on:

  • getElementById returns a general HTMLElement | null (or similar), because it can’t know what element you’re selecting.
  • ! is the non-null assertion (“trust me, it exists”). Use it carefully—if the element is missing, your code will still crash at runtime.
  • as HTMLInputElement narrows the element type so .value is allowed.

Safer alternatives in real code:

  • Check for null (if (!el) return) before using it.
  • Narrow by checking tagName or using querySelector<HTMLInputElement>(...) in some setups.

Index signatures (index properties)

Index signatures let you define “this object can have unknown property names” with a consistent value type.

interface ErrorContainer { /* {email: 'not a valid email', username: 'Must start with a character'*/ id:string; [prop: string]: string; // don't know name or property count, but all properties added must be a property name as string and value as string. Index type. } const errorBag: ErrorContainer = { email: 'Not a valid email.' 12: 'Not a valid number' // number can be interpreted as string, but not the other way around. }

Why you’d use this:

  • Validation errors are often dynamic: email, username, password, etc.
  • You don’t want to predefine every possible key, but you want to enforce that every message is a string.

Two important constraints:

  • Once you declare [prop: string]: string, all string-keyed properties must have string values (that’s why this works well for error message “bags”).
  • Numeric keys are allowed because JS coerces them to strings internally (errorBag[12] becomes errorBag["12"]).

Function overloads

Overloads let you provide multiple call signatures so TypeScript can infer a more precise return type depending on the inputs.

type Combinable = number | string; function add(a: number, b: number):number // function overload. function add(a: string, b: string):string function add(a: Combinable, b: Combinable) { if(typeof a ==='string' || typeof b === 'string') { return a.toString() + b.toString() } return a+b } const result = add(1,5);// result is of type combinable. It doesn't know that result is a number. This limits methods that we can use on it. result.split(' ') // or result.toString() for a number

What overloads do (conceptually):

  • They tell TS: “if you call add with two numbers, you get a number; if you call it with two strings, you get a string.”
  • The last add(...) is the implementation signature and must be compatible with all overload signatures.

Practical nuance:

  • If you want to support mixed calls like add("a", 1), you’d add an overload for that too—or disallow it by design.

Optional chaining

Optional chaining (?.) is for safely accessing properties when something might be null/undefined.

fetchedUserData = { id: 'u1', name: 'Max', job: { title: 'CEO', description: 'My own company'} } console.log(fetchedUserData?.job?.title);

How it behaves:

  • If fetchedUserData is null/undefined, the expression returns undefined instead of throwing.
  • If job is missing, it stops and returns undefined.
  • This is ideal for DB/API data where optional nested objects are common.

Nullish coalescing

The nullish coalescing operator (??) picks a fallback only when the left side is null or undefined.

//const userInput = null; // assume a failed DB call. const userInput = ''; //const storedData = userInput || 'Default' // chooses default even with empty string const storedData = userInput ?? 'Default'; console.log(storedData);

Why ?? is different from ||:

  • || treats all falsy values ("", 0, false) as “missing.”
  • ?? treats only null and undefined as “missing.”

So "" ?? "Default" stays "", which is often what you want for user-entered strings.


Generics

Generics are a TypeScript feature (compile-time only) that let you write reusable code while preserving type information.

Built-in generics: arrays and promises

  • Array<T> is a generic type; it needs the element type.

    • any[] is the same concept as Array<any>
  • Promise<T> is generic too:

    • Promise<unknown> means “this resolves to something, but we’ll narrow later.”

Creating your own generic functions

Non-generic merge loses detail:

function merge(objA: object, objB: object){ return Object.assign(objA,objB); } //console.log(merge({name: 'max'},{age:40}));// works fine. mergedObj = merge({name: 'max'},{age:40}) // cannot find name or age here.

Generic merge preserves detail:

function merge<T, U>(objA: T, objB: U){ return Object.assign(objA,objB); }// typescript detects that it returns t & u

Why it improves things:

  • TS infers T and U from the arguments.
  • The return type becomes T & U, so the merged object “has both sets of properties” from the type system’s perspective.

Constraining generics

To avoid passing non-objects:

function merge<T extends object, U extends object>(objA: T, objB: U){ return Object.assign(objA,objB); }// typescript detects that it returns t & u

This answers your question:

How might we ensure that a data structure has a particular property or method in TS?

Use a generic constraint (extends) against an interface/type that includes that property/method.

Example: require a .length:

interface Lengthy{ length: number } function counAndDescribe<T extends Lengthy>(element: T){ let descriptionText = 'got no value.'; if(element.length>0) descriptionText = `Got ${element.length} element(s)` return [element, descriptionText] } // extending lengthy means that we expect something that has a length property.

This guarantees element.length is valid, regardless of whether element is a string, array, custom object, etc.


keyof constraints

keyof produces a union of valid keys from a type. In generics, it’s commonly used to ensure you can only pass keys that actually exist on the object.

function extractAndConvert<T extends object, U extends keyof T (obj:T,key: U){ return obj[key]; //throws error, since we don't know that obj has a key property. } extractAndConvert({name:'Max'}, 'name');

What keyof is used for:

  • Prevents invalid property access like extractAndConvert({ name: "Max" }, "age").
  • Makes dynamic indexing type-safe (obj[key]) because key is guaranteed to be a real key of obj.

(Separately: the snippet has a small syntax issue in the generic parameter list, but the concept you’re capturing is exactly what keyof is for.)


Generic classes + constraints (and why primitives matter)

class DataStorage<T extends string | number | boolean>{ private data: T[] = []; addItem(item: T){ this.data.push(item); } removeItem(item:T){ if(this.data.indexOf(item)===-1) return; this.data.splice(this.data.indexOf(item),1); } getItems(){ return [...this.data]; } } const textStorage = new DataStorage<string>(); textStorage.addItem('Max'); // works // textStorage.addItem(1) // fails. //one Issue const objStorage = new DataStorage<object>(); objStorage.addItem({name:'Max'}) objStorage.addItem({name:'Manu'}) //... objStorage.removeItem({name: 'Max'}); console.log(objStorage.getItems()); // this still gives max. because indexOf cannot search for objects properly. it returns -1

Why constrain to primitives?

  • indexOf uses reference equality for objects.

    • {name:'Max'} is not the same reference as another {name:'Max'}, so indexOf can’t find it.
  • With primitives (string | number | boolean), equality behaves as you expect.

If you want object storage, typical solutions are:

  • store objects with stable IDs and remove by ID
  • accept a comparator function
  • use a Map keyed by ID

Generic utility types (intro)

Utility types are built-in helpers that transform other types (e.g., making properties optional, readonly, etc.).

Your snippet sets up a common situation: you want to build an object step-by-step but still return a fully-typed result.

interface CourseGoal{ title:string; description: string; completeUntil: Date; } function createCourseGoal (title:string, description: string, date:Date): CourseGoal { return {title, description, completeUntil:date}; }

The “generic utility types” direction here typically leads to patterns like:

  • constructing a Partial<CourseGoal> first,
  • filling it,
  • then returning it as CourseGoal.