28 — TypeScript Essentials: Type System, Generics & Conditional Types
Structural Typing vs Nominal Typing
typescript
// ── TypeScript uses STRUCTURAL typing (duck typing) ──
// Objects are compatible if their SHAPE matches — names don't matter.
interface User { id: number; name: string; }
interface Product { id: number; name: string; }
const user: User = { id: 1, name: "Alice" };
const product: Product = user; // ✅ compiles (same shape — structural compatibility)
// In nominal typing (Java, C#), this would fail (different types by name).
// ── Branded types: simulate nominal typing (distinguish same-shaped types) ──
type UserId = number & { readonly __brand: "UserId" };
type ProductId = number & { readonly __brand: "ProductId" };
function getUser(id: UserId) { /* ... */ }
function getProduct(id: ProductId) { /* ... */ }
const userId = 1 as UserId; // must cast (branding at creation)
const productId = 2 as ProductId;
getUser(userId); // ✅ correct brand
getProduct(userId); // ❌ Type 'UserId' is not assignable to type 'ProductId'
// Brands prevent mixing same-shaped types (UserId vs ProductId) — runtime safety.
type vs interface
typescript
// ── interface: object shapes, can be merged (declaration merging) ──
interface Window {
customProp: string; // augments the global Window interface
}
interface Window { // declaration merging — adds to the existing interface
anotherProp: number;
}
// window.customProp; window.anotherProp; // both available
// ── type: more flexible (unions, intersections, primitives, mapped types) ──
type ID = number | string; // union (interface can't do this)
type Callback<T> = (value: T) => void; // function type
type Nullable<T> = T | null; // conditional/generic type alias
type Pair = [string, number]; // tuple (interface can't do this)
// ── When to use which ──
// interface: object shapes, class implementations, extensible (merging), public API
// type: unions, intersections, tuples, mapped types, utility types, internal types
// ── Extending ──
interface Animal { name: string; }
interface Dog extends Animal { bark(): void; } // interface extends interface
type Animal2 = { name: string };
type Dog2 = Animal2 & { bark(): void }; // type intersection (&)
// ── Both can represent object shapes ──
type UserType = { id: number; name: string };
interface UserInterface { id: number; name: string; }
// Prefer interface for objects (extensible, better error messages)
// Prefer type for unions, intersections, and complex type logic
Generics with Constraints
typescript
// ── Generic functions: preserve the input type through the function ──
function identity<T>(value: T): T { // T is a type parameter
return value;
}
const result = identity("hello"); // T is inferred as string → result: string
const num = identity(42); // T is inferred as number → num: number
// ── Generic constraints: restrict what T can be (extends) ──
function getLength<T extends { length: number }>(value: T): number {
return value.length; // T must have a `length` property
}
getLength("hello"); // ✅ string has length
getLength([1, 2, 3]); // ✅ array has length
getLength(123); // ❌ number doesn't have length
// ── keyof constraint: get keys of a type ──
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]; // return type is T[K] (the type of the property at key K)
}
const user = { name: "Alice", age: 30 };
getProperty(user, "name"); // returns string (type of user.name)
getProperty(user, "age"); // returns number (type of user.age)
getProperty(user, "email"); // ❌ "email" is not a key of user
// ── Generic class: type parameter on the class ──
class Stack<T> {
private items: T[] = [];
push(item: T): void { this.items.push(item); }
pop(): T | undefined { return this.items.pop(); }
peek(): T | undefined { return this.items[this.items.length - 1]; }
}
const numberStack = new Stack<number>();
numberStack.push(1); // ✅ number
numberStack.push("a"); // ❌ string is not assignable to number
// ── Default type parameters ──
function createArray<T = string>(length: number, value: T): T[] {
return Array.from({ length }, () => value);
}
createArray(3, "x"); // T=string → string[]
createArray<number>(3, 0); // T=number → number[] (explicit)
Conditional Types and Inference
typescript
// ── Conditional types: type-level if/else ──
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<string>; // "yes"
type B = IsString<number>; // "no"
// ── infer: extract a type from another type (within a conditional) ──
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
// If T is a function, infer its return type as R; otherwise, never.
type F = ReturnType<() => string>; // string
type G = ReturnType<(x: number) => boolean>; // boolean
// ── Extract promise inner type ──
type Awaited2<T> = T extends Promise<infer U> ? U : T;
type H = Awaited2<Promise<number>>; // number
type I = Awaited2<string>; // string (not a promise → returns itself)
// ── Extract array element type ──
type ElementOf<T> = T extends (infer E)[] ? E : never;
type J = ElementOf<string[]>; // string
type K = ElementOf<number[]>; // number
// ── Extract function parameters ──
type FirstParam<T> = T extends (first: infer P, ...rest: any[]) => any ? P : never;
type L = FirstParam<(name: string, age: number) => void>; // string
// ── Distributive conditional types (union distributes) ──
type ToArray<T> = T extends any ? T[] : never;
type M = ToArray<string | number>; // string[] | number[] (distributed over the union)
// NOT (string | number)[] — each union member is processed separately.
Mapped Types and Utility Types
typescript
// ── Mapped types: transform properties (type-level loops) ──
type Readonly<T> = {
readonly [K in keyof T]: T[K]; // iterate over keys, make each readonly
};
type Optional<T> = {
[K in keyof T]?: T[K]; // make each property optional
};
type Nullable<T> = {
[K in keyof T]: T[K] | null; // make each property nullable
};
interface User { id: number; name: string; email: string; }
type ReadonlyUser = Readonly<User>; // { readonly id: number; readonly name: string; ... }
type OptionalUser = Optional<User>; // { id?: number; name?: string; ... }
type NullableUser = Nullable<User>; // { id: number | null; name: string | null; ... }
// ── Key remapping (TypeScript 4.1+) ──
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
// remap key: `getName`, `getEmail`, etc. (template literal + Capitalize)
};
type UserGetters = Getters<User>;
// { getId: () => number; getName: () => string; getEmail: () => string }
// ── Built-in utility types ──
type PartialUser = Partial<User>; // all properties optional
type RequiredUser = Required<User>; // all properties required (remove ?)
type ReadonlyUser2 = Readonly<User>; // all properties readonly
type PickUser = Pick<User, "id" | "name">; // { id: number; name: string } (select keys)
type OmitUser = Omit<User, "email">; // { id: number; name: string } (remove keys)
type RecordType = Record<string, number>; // { [key: string]: number } (key → value map)
type ReturnTypeT = ReturnType<() => string>; // string (function return type)
type ParametersT = Parameters<(x: number, y: string) => void>; // [number, string]
type AwaitedT = Awaited<Promise<string[]>>; // string[] (unwrap Promise)
type ExcludeT = Exclude<"a" | "b" | "c", "a">; // "b" | "c" (remove from union)
type ExtractT = Extract<"a" | "b" | "c", "a" | "b">; // "a" | "b" (keep from union)
type NonNullableT = NonNullable<string | null | undefined>; // string (remove null/undefined)
Type Narrowing and Guards
typescript
// ── Type narrowing: refine the type within a conditional branch ──
function process(value: string | number) {
if (typeof value === "string") {
value.toUpperCase(); // narrowed to string
} else {
value.toFixed(2); // narrowed to number
}
}
// ── instanceof narrowing (class types) ──
function handleError(error: Error | string) {
if (error instanceof Error) {
error.message; // narrowed to Error
} else {
error.trim(); // narrowed to string
}
}
// ── in narrowing (property check) ──
interface Cat { meow(): void; }
interface Dog { bark(): void; }
function speak(animal: Cat | Dog) {
if ("meow" in animal) {
animal.meow(); // narrowed to Cat
} else {
animal.bark(); // narrowed to Dog
}
}
// ── Custom type guards (user-defined type predicates) ──
function isString(value: unknown): value is string { // `value is string` is the predicate
return typeof value === "string";
}
function processUnknown(value: unknown) {
if (isString(value)) {
value.toUpperCase(); // narrowed to string (type guard worked)
}
}
// ── Discriminated unions (narrowing on a common property) ──
type Result =
| { status: "success"; data: string }
| { status: "error"; message: string };
function handle(result: Result) {
if (result.status === "success") {
result.data; // narrowed to the success variant
} else {
result.message; // narrowed to the error variant
}
}
Declaration Files (.d.ts)
typescript
// ── Declaration files: type definitions for JavaScript libraries ──
// .d.ts files contain only types (no runtime code) — describe the shape of a JS module.
// ── global.d.ts: declare global types ──
declare global {
interface Window {
myCustomAPI: {
init: () => void;
track: (event: string) => void;
};
}
}
// Now `window.myCustomAPI` is typed throughout the project.
// ── module.d.ts: declare types for an untyped JS module ──
// untyped-lib.d.ts:
declare module "untyped-lib" {
export function greet(name: string): string;
export const version: string;
export type Config = { timeout: number; retries: number };
}
// ── Ambient declarations (declare without implementation) ──
declare const API_URL: string; // provided by webpack DefinePlugin or Vite define
// tsconfig.json: "compilerOptions": { "typeRoots": ["./node_modules/@types", "./src/types"] }
// ── DefinitelyTyped (@types/*) ──
// npm install --save-dev @types/express → provides express.d.ts
// If a library ships its own types (package.json "types" field), no @types needed.
tsconfig.json Configuration
json
{
"compilerOptions": {
"target": "ES2022", // output JS version
"module": "ESNext", // module system (ESM)
"moduleResolution": "bundler",// how modules are resolved (bundler for Vite/webpack)
"lib": ["ES2022", "DOM"], // available type definitions
"strict": true, // enable all strict checks (recommended)
"noUncheckedIndexedAccess": true, // arr[0] is T | undefined (not just T)
"noImplicitReturns": true, // error if not all code paths return
"noFallthroughCasesInSwitch": true, // error on switch fall-through
"exactOptionalPropertyTypes": true, // optional props can't be explicitly undefined
"esModuleInterop": true, // allow default import from CJS modules
"skipLibCheck": true, // skip type checking of .d.ts files (faster)
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true, // allow importing .json files
"isolatedModules": true, // each file is compiled independently (Vite requirement)
"verbatimModuleSyntax": true, // enforce type-only imports (import type)
"outDir": "./dist", // output directory
"rootDir": "./src", // source root
"baseUrl": ".", // base for path mapping
"paths": { "@/*": ["./src/*"] } // path alias: import from "@/..."
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
💡 Tips & Tricks
typescript
// ── `as const`: make everything readonly and literal ──
const config = { env: "production", port: 3000 } as const;
// type: { readonly env: "production"; readonly port: 3000 } (literal types, not string/number)
// ── `satisfies`: check a type without widening ──
const routes = {
home: "/",
about: "/about",
} satisfies Record<string, string>;
// `satisfies` checks the type but keeps the literal types (home: "/", not string)
// ── `import type`: type-only imports (erased at runtime) ──
import type { User } from "./types.js"; // no runtime import (just types)
import { getUser } from "./api.js"; // runtime import (value)
// ── `unknown` is safer than `any` (forces narrowing) ──
function process(value: unknown) {
// value.foo(); // ❌ error — unknown has no properties
if (typeof value === "string") {
value.toUpperCase(); // ✅ narrowed to string
}
}
// `any` disables all checks — `unknown` forces you to narrow before use.
// ── Exhaustive checking (never type) ──
type Status = "pending" | "active" | "inactive";
function handle(status: Status) {
switch (status) {
case "pending": return 1;
case "active": return 2;
case "inactive": return 3;
default:
const _exhaustive: never = status; // error if a case is missing
throw new Error(`unhandled: ${_exhaustive}`);
}
}
// If you add "archived" to Status, the default branch fails (status is not never).
⚠️ Edge Cases & Gotchas
typescript
// ── TypeScript types are erased at runtime (no runtime type checking) ──
function isUser(value: any): value is User {
return typeof value?.id === "number" && typeof value?.name === "string";
// Can't use `value instanceof User` (interface has no runtime presence).
// Must manually check properties (structural validation).
}
// ── `any` disables type checking (escape hatch — avoid) ──
// Use `unknown` instead (forces narrowing before use).
// ── `null` and `undefined` are different types ──
// strict: true enables strictNullChecks — null and undefined are not assignable to other types.
// string | undefined ≠ string | null ≠ string | null | undefined
// ── Excess property checking only applies to object literals ──
interface User { id: number; name: string; }
const user: User = { id: 1, name: "Alice", extra: "x" }; // ❌ excess property
const obj = { id: 1, name: "Alice", extra: "x" };
const user2: User = obj; // ✅ no excess check (obj is not a literal — structural match)
// ── Type assertions don't change runtime behavior ──
const value: any = "hello";
const num: number = value as number; // compiles, but runtime: num is still "hello" (string)
// `as` only affects the compiler — no runtime conversion. Use Number(value) for conversion.
// ── `interface` merging can cause surprises ──
// If you declare the same interface twice, they merge (additive).
// This can accidentally augment global types (like Window) — be careful.
🧠 Quick Quiz
Why does this fail, and how do you fix it?
typescript
type EventName<T> = T extends `on${infer E}` ? E : never;
type Handler<T> = (event: T) => void;
function on<T extends string>(event: T, handler: Handler<EventName<T>>): void {
// ...
}
on("onClick", (event) => { /* what is the type of `event`? */ });
Answer
The type of event is "Click" — the literal string "Click", not a generic event object.
Here's why:
Tis inferred as"onClick"(the literal string, becauseT extends stringand the argument is a string literal).EventName<"onClick">evaluates:"onClick" extends \on${infer E}` ? E : never`.- The template literal pattern
\on${infer E}`matches"onClick", soEis inferred as"Click"`. Handler<EventName<"onClick">>=Handler<"Click">=(event: "Click") => void.
So the handler receives the literal string "Click" — probably not what you want.
Fix: If you want the handler to receive an actual event object (not a string), define a proper event type:
interface ClickEvent { target: HTMLElement; type: "click"; }
type EventMap = { onClick: ClickEvent; onHover: { type: "hover" } };
function on<K extends keyof EventMap>(event: K, handler: (e: EventMap[K]) => void): void {
// ...
}
on("onClick", (event) => { event.target; }); // event: ClickEvent (properly typed)
The lesson: template literal types and infer extract string fragments at the type level — they produce string literal types, not runtime values. If you need proper event types, use a mapped type (EventMap) that maps event names to their payload types.