// selectedUserId can hold either a string or null
Note With strictNullChecks enabled (recommended), null and undefined are NOT assignable to other types unless you explicitly include them in a union. This catches a huge class of runtime bugs.
null typeundefined typenullablestrictNullChecksoptional value
any
syntax
let varName: any = value;
example
let legacyPayload: any = fetchFromOldApi();
legacyPayload.whatever.you.want; // No error
legacyPayload = 42;
legacyPayload = "now a string";
output
// Compiles without error — type checking is completely disabled
Note any disables ALL type safety for that value. It propagates silently — anything touching an any value also loses type info. Use unknown instead when you genuinely do not know the type.
any typedisable type checkingopt out typesuntyped variable
unknown
syntax
let varName: unknown = value;
example
let rawInput: unknown = JSON.parse(userInput);
// Must narrow before usingif (typeof rawInput === "string") {
console.log(rawInput.toUpperCase()); // OK
}
// rawInput.toUpperCase(); // Error: Object is of type 'unknown'
output
// Forces you to check the type before accessing properties
Note unknown is the type-safe counterpart to any. You can assign anything to unknown, but you cannot do anything with it until you narrow the type. Prefer unknown over any for incoming external data.
function throwAppError(msg: string): never {
thrownew Error(msg);
}
// Used for exhaustive checkstype Shape = "circle" | "square";
function getArea(shape: Shape): number {
switch (shape) {
case"circle": return Math.PI * 10;
case"square": return100;
default:
const _exhaustive: never = shape;
return _exhaustive;
}
}
output
// never means this code path should be unreachable
Note never represents values that never occur. A function returning never must not return normally (throw or infinite loop). Assigning to never in a default branch ensures you handle every union member — adding a new Shape member causes a compile error.
never typeexhaustive checkunreachable codethrow function type
// void indicates the function does not return a meaningful value
Note void is not the same as undefined. A void return type means the caller should not use the return value, but the function may technically return undefined. When used in callback types, void allows the implementation to return anything (the return is just ignored).
void typeno returnfunction returns nothingvoid vs undefined
symbol & bigint
syntax
let varName: symbol = Symbol(description);
let varName: bigint = valueBigInt;
example
const uniqueKey: unique symbol = Symbol("cacheKey");
let regularSym: symbol = Symbol("temp");
let hugeNumber: bigint = 9007199254740993n;
let anotherBig: bigint = BigInt("123456789012345678");
output
// unique symbol creates a distinct type; bigint handles arbitrarily large integers
Note unique symbol can only be used with const declarations and creates a specific subtype of symbol. bigint cannot be mixed with number in arithmetic — you must explicitly convert. bigint requires target ES2020 or later in tsconfig.
symbol typeunique symbolbigint typelarge numbersES2020 bigint
Arrays & Tuples
syntax
let arr: Type[] = [...];
let arr: Array<Type> = [...];
let tup: [TypeA, TypeB] = [a, b];
// userRecord[0] is string, userRecord[1] is number, userRecord[2] is boolean
Note Type[] and Array<Type> are identical. Tuples enforce length and per-position types at compile time, but at runtime they are just arrays — push() can still add elements unless you mark the tuple as readonly.
let varName: { key: Type; key2?: Type } = { ... };
let varName: object = { ... };
example
let product: { name: string; price: number; inStock?: boolean } = {
name: "Keyboard",
price: 79.99,
};
// The lowercase 'object' type means any non-primitivelet config: object = { debug: true };
// config.debug; // Error — object type has no known properties
output
// Use inline object types or interfaces for known shapes
Note Avoid the bare object type — it only means 'not a primitive' and has no property access. Use Record<string, unknown> for truly open-ended objects. The Object type (uppercase) matches nearly everything and is rarely useful.
object typetyped objectinline object typeobject vs Object
let varName: Type = value;
const varName: Type = value;
example
let orderId: string = "ORD-7821";
const maxRetries: number = 3;
let tags: string[] = ["urgent", "billing"];
// Often unnecessary when the type is obviouslet count = 0; // inferred as numberconst label = "total"; // inferred as literal "total"
output
// Explicit annotations override inference when needed
Note Let TypeScript infer when the type is obvious from the initializer. Add explicit annotations when: the inferred type is too wide, you are declaring without initializing, or you want documentation clarity.
annotate variabletype annotationdeclare typed variableexplicit type
Function Parameter Types
syntax
function fn(param: Type, param2: Type): ReturnType { ... }
example
function calculateTotal(
items: { name: string; price: number }[],
taxRate: number
): number {
const subtotal = items.reduce((sum, item) => sum + item.price, 0);
return subtotal * (1 + taxRate);
}
output
// Parameters MUST be annotated — TypeScript does not infer param types
Note Function parameters are never inferred from usage. Always annotate them. The return type is usually inferred correctly, but annotating it explicitly catches mistakes when the function body changes.
function parameter typetyped functionannotate parametersfunction arguments type
Note Explicit return types are especially valuable for public API functions, async functions, and functions with multiple return paths. They also speed up type-checking in large projects since the compiler does not need to analyze the function body.
return typefunction return typeasync return typePromise type
Type Inference
syntax
// No annotation needed — TypeScript figures it outlet varName = value;
example
let customerName = "Kenji"; // stringconst port = 8080; // literal type 8080 (const narrows)let results = [1, 2, 3]; // number[]let mixed = [1, "two", true]; // (string | number | boolean)[]// Return type inferencefunction double(n: number) {
return n * 2; // inferred return: number
}
output
// const uses literal types; let uses widened types
Note const declarations infer the narrowest (literal) type, while let declarations widen to the base type. This is called 'widening'. Use 'as const' to get literal types with let, or on objects/arrays to make them deeply readonly with literal types.
type inferenceinferred typeautomatic typewideninglet vs const inference
// Assertions override the compiler — they do NOT perform runtime conversion
Note Type assertions are a compile-time escape hatch — no runtime checking or conversion happens. If you assert incorrectly, you get silent bugs. Prefer type guards (typeof, instanceof) when possible. The angle-bracket syntax <Type>value conflicts with JSX — always use 'as Type' in .tsx files.
type assertionas keywordcast typeoverride typeangle bracket assertion
Function Overloads
syntax
function fn(param: TypeA): ReturnA;
function fn(param: TypeB): ReturnB;
function fn(param: TypeA | TypeB): ReturnA | ReturnB { ... }
example
function parseInput(input: string): string[];
function parseInput(input: number): number[];
function parseInput(input: string | number): string[] | number[] {
if (typeof input === "string") {
return input.split(",");
}
return Array.from({ length: input }, (_, i) => i);
}
const words = parseInput("a,b,c"); // string[]const nums = parseInput(5); // number[]
output
// Caller sees the specific overload, not the implementation signature
Note The implementation signature is not callable directly — only the overload signatures are visible to callers. Order matters: TypeScript picks the first matching overload. Put more specific signatures before general ones.
function overloadmultiple signaturesoverloaded functiondifferent return types
Note Interfaces define the shape of objects. They are structurally typed — any object with the right shape satisfies the interface, no explicit 'implements' needed for plain objects.
Note An optional property (prop?) means 'may be missing or undefined'. This is different from prop: Type | undefined, which requires the key to be present. With exactOptionalPropertyTypes in tsconfig, this distinction is enforced even more strictly.
optional propertyquestion mark propertyoptional fieldmaybe undefined
Readonly Properties
syntax
interface Name {
readonly property: Type;
}
example
interface DatabaseRecord {
readonly id: string;
readonly createdAt: Date;
name: string;
updatedAt: Date;
}
const record: DatabaseRecord = {
id: "rec_abc",
createdAt: new Date(),
name: "Initial",
updatedAt: new Date(),
};
record.name = "Updated"; // OK// record.id = "rec_xyz"; // Error: Cannot assign to 'id' because it is read-only
output
// readonly prevents reassignment at compile time only
Note readonly is a compile-time constraint — there is no runtime enforcement. Deep objects with readonly props can still have their nested properties mutated unless those are also readonly. Use Readonly<T> utility to make all properties readonly at once.
// Multiple inheritance via comma-separated parent interfaces
Note An interface can extend multiple other interfaces. If parent interfaces have conflicting property types, you get a compile error. Interfaces can also extend type aliases (as long as the alias resolves to an object shape).
// Class must define all properties/methods declared in the interface
Note implements does NOT add types to the class — it only checks that the class satisfies the shape. You must still annotate parameter types in the class methods. A class can implement multiple interfaces with comma separation.
// greetings["fr"] is string (even if missing at runtime)
Note Index signatures allow any key of the given type. When combined with named properties, all named property types must be compatible with the index signature type. Enable noUncheckedIndexedAccess in tsconfig to get T | undefined instead of just T for indexed access — much safer.
index signaturedynamic keysstring indexdictionary typemap type
Declaration Merging
syntax
interface Name { propA: Type; }
interface Name { propB: Type; }
// Merged into one interface with both props
example
// Original library typeinterface Window {
title: string;
}
// Your augmentation — merges with the aboveinterface Window {
analyticsId: string;
}
// Now Window has both title and analyticsIdconst w: Window = {
title: "My App",
analyticsId: "UA-12345",
};
output
// Both declarations merge into a single interface
Note Declaration merging is unique to interfaces — type aliases cannot be re-opened. This is powerful for extending third-party types (like adding properties to Window or Express Request). The merged properties must not conflict in type.
type UserId = string;
type Coordinate = { x: number; y: number };
type StringOrNumber = string | number;
type Callback = (data: unknown) => void;
const origin: Coordinate = { x: 0, y: 0 };
const userId: UserId = "usr_9f3a";
output
// Type aliases create a name for any type expression
Note Type aliases and interfaces overlap for object shapes, but type aliases can also name unions, tuples, primitives, and function types. Unlike interfaces, type aliases cannot be re-declared for merging. Use interfaces for object shapes that might be extended; use type aliases for everything else.
type aliastype keywordcustom typenamed typetype vs interface
Union Types
syntax
type Name = TypeA | TypeB | TypeC;
example
type Status = "pending" | "active" | "archived";
type ApiResponse = SuccessResult | ErrorResult;
interface SuccessResult {
ok: true;
data: unknown;
}
interface ErrorResult {
ok: false;
errorCode: number;
message: string;
}
function handleResponse(res: ApiResponse) {
if (res.ok) {
console.log(res.data); // narrowed to SuccessResult
} else {
console.error(res.message); // narrowed to ErrorResult
}
}
output
// Union means 'one of these types' — must narrow before accessing specific members
Note You can only access properties common to ALL members of a union without narrowing first. Use a shared discriminant property (like 'ok' above) to let TypeScript narrow automatically in conditionals.
union typeor typemultiple typeseither typediscriminated union
Intersection Types
syntax
type Name = TypeA & TypeB;
example
type WithId = { id: string };
type WithTimestamps = { createdAt: Date; updatedAt: Date };
type UserRecord = WithId & WithTimestamps & {
email: string;
displayName: string;
};
const user: UserRecord = {
id: "usr_001",
email: "[email protected]",
displayName: "Dev User",
createdAt: new Date(),
updatedAt: new Date(),
};
output
// Intersection combines all properties from all types
Note Intersection means 'all of these combined'. If two types have the same property with incompatible types, the intersected property becomes never (which usually means a mistake). Intersections are great for mixin-style composition.
intersection typecombine typesand typemerge typesmixin type
Literal Types
syntax
type Name = "exactValue" | "anotherValue";
type Name = 0 | 1 | 2;
example
type Direction = "north" | "south" | "east" | "west";
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
function move(direction: Direction, steps: number) {
console.log(`Moving ${direction} by ${steps}`);
}
move("north", 3); // OK// move("up", 3); // Error: Argument of type '"up"' is not assignable
output
// Literal types restrict a value to an exact set of allowed values
Note Literal types turn strings, numbers, or booleans into specific-value types. Combined with unions, they create powerful enumerations without the overhead of enum. const assertions (as const) automatically infer literal types.
literal typestring literalexact value typespecific valuesenum alternative
Note Template literal types construct new string types from combinations. They distribute over unions — every combination is produced. Built-in string manipulation types (Capitalize, Uppercase, Lowercase, Uncapitalize) work inside template literals. Extremely powerful for typing DSLs and API patterns.
template literal typestring pattern typedynamic string typestring manipulation type
Generic Type Aliases
syntax
type Name<T> = { ... };
type Name<T, U = DefaultType> = ...;
example
type ApiResult<T> = {
data: T;
status: number;
timestamp: Date;
};
type Nullable<T> = T | null;
type Pair<A, B = A> = [A, B];
const userResult: ApiResult<{ name: string }> = {
data: { name: "Priya" },
status: 200,
timestamp: new Date(),
};
const point: Pair<number> = [10, 20]; // both numberconst labeled: Pair<string, number> = ["x", 42];
output
// Generic aliases accept type parameters just like functions accept value parameters
Note Generic type aliases are essential for building reusable type utilities. Default type parameters (B = A) make common cases convenient while keeping the alias flexible. You can also add constraints with extends.
generic type aliasparameterized typereusable typetype with generic
// TypeScript infers T from the argument — no need to specify it manually
Note Type parameters are inferred from arguments in most cases. Only specify them explicitly when inference gives the wrong type. In .tsx files, arrow generics need a trailing comma: <T,>(param: T) to avoid JSX ambiguity.
generic functiontype parameterparameterized functionreusable function type
// Repository<User> locks T to User for the entire implementation
Note Generic interfaces are ideal for defining contracts that work across multiple entity types — repositories, services, collections. Each implementation locks in the concrete type.
class Name<T> {
constructor(private value: T) {}
}
example
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
returnthis.items.pop();
}
peek(): T | undefined {
returnthis.items[this.items.length - 1];
}
get size(): number {
returnthis.items.length;
}
}
const numberStack = new Stack<number>();
numberStack.push(10);
numberStack.push(20);
const top = numberStack.pop(); // number | undefined
output
// top → 20
Note Static members of a generic class cannot reference the class type parameter — the parameter only exists on instances. If you need generic static methods, make the method itself generic rather than the class.
generic classtyped classparameterized classdata structure class
Generic Constraints
syntax
function fn<T extends ConstraintType>(param: T): T { ... }
example
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(item: T): T {
console.log(`Length: ${item.length}`);
return item;
}
logLength("hello"); // string has .length
logLength([1, 2, 3]); // arrays have .length// logLength(42); // Error: number has no .length// Constrain to object keysfunction getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const color = getProperty({ r: 255, g: 128, b: 0 }, "g"); // number
output
// Constraints restrict what types a generic can accept
Note Use extends to set a minimum requirement for a type parameter. The constraint keyof T is extremely useful — it restricts a parameter to valid property names of another type, enabling fully type-safe property access.
// Default types make generics more ergonomic for common cases
Note Default type parameters work like default function arguments — they must come after required type parameters. A default does not constrain the type; add extends if you also need a constraint: <T extends object = Record<string, unknown>>.
default type parametergeneric defaultoptional type parameterdefault generic
Building Utilities with Generics
syntax
type UtilityName<T> = { [K in keyof T]: TransformedType };
example
// Make all properties optional and nullabletype Draft<T> = {
[K in keyof T]?: T[K] | null;
};
interface Article {
title: string;
body: string;
publishedAt: Date;
}
type ArticleDraft = Draft<Article>;
// { title?: string | null; body?: string | null; publishedAt?: Date | null }const draft: ArticleDraft = {
title: "Work in progress",
};
output
// Custom utility types combine generics with mapped types
Note This pattern (mapped types + generics) is how TypeScript's built-in utility types (Partial, Required, Readonly) are implemented. Understanding this lets you build project-specific utilities tailored to your domain.
custom utility typemapped type genericbuild utilitytransform type
Generics with Conditional Types
syntax
type Name<T> = T extends Condition ? TrueType : FalseType;
example
type IsArray<T> = T extends any[] ? true : false;
type Test1 = IsArray<string[]>; // truetype Test2 = IsArray<number>; // false// Extracting element typestype ElementOf<T> = T extends (infer E)[] ? E : never;
type StrEl = ElementOf<string[]>; // stringtype NumEl = ElementOf<number[]>; // numbertype NoEl = ElementOf<boolean>; // never
output
// Conditional types act like type-level if/else
Note When a conditional type receives a union as T, it distributes — the condition is applied to each union member independently. To prevent distribution, wrap both sides in brackets: [T] extends [Condition].
conditional generictype conditionalgeneric if elseinfer keyworddistribute over union
interface Settings {
theme: "light" | "dark";
fontSize: number;
notifications: boolean;
}
function updateSettings(current: Settings, changes: Partial<Settings>): Settings {
return { ...current, ...changes };
}
const updated = updateSettings(
{ theme: "light", fontSize: 14, notifications: true },
{ fontSize: 16 } // only need to pass what changed
);
output
// Partial<Settings> makes all properties optional
Note Partial only operates one level deep. Nested objects keep their original required types. For deep partial, you need a custom recursive type or a library utility.
Partialall optionalpartial updateoptional propertiesupdate function
Required<T>
syntax
type Result = Required<OriginalType>;
example
interface FormFields {
username?: string;
email?: string;
password?: string;
}
// After validation, all fields must be presenttype ValidatedForm = Required<FormFields>;
const validated: ValidatedForm = {
username: "alice",
email: "[email protected]",
password: "s3cureP@ss",
};
// Missing any field → compile error
output
// Required<T> removes all ? optional markers
Note Required is the inverse of Partial. Like Partial, it only works one level deep. Useful for representing the 'after-validation' state where you know all optional fields have been filled in.
// Readonly<T> marks all top-level properties as readonly
Note Readonly only applies to the top level. frozen.user.name = 'B' would still compile because the nested object is not frozen. For deep immutability, use 'as const' on literals, or build a DeepReadonly recursive type.
Readonlyimmutable typefreeze typereadonly all properties
Pick<T, Keys>
syntax
type Result = Pick<OriginalType, "key1" | "key2">;
// EmployeePreview has only id, name, and department
Note Pick creates a new type with only the specified keys. The keys must actually exist on T — typos cause compile errors. Pick is great for API response shapes where you only need a subset of fields.
Pickselect propertiessubset typepick fieldsinclude only
Omit<T, Keys>
syntax
type Result = Omit<OriginalType, "key1" | "key2">;
example
interface BlogPost {
id: string;
title: string;
body: string;
authorId: string;
createdAt: Date;
}
type CreatePostInput = Omit<BlogPost, "id" | "createdAt">;
const newPost: CreatePostInput = {
title: "Getting Started with TypeScript",
body: "TypeScript adds type safety to JavaScript...",
authorId: "usr_007",
};
output
// CreatePostInput has title, body, authorId (no id or createdAt)
Note Omit does NOT enforce that the keys exist on T — you can omit keys that are not present without error. This is a known gotcha; misspelled keys silently pass. Use a custom StrictOmit if you need safety.
Omitexclude propertiesremove fieldswithout keysomit type
// Record forces every Role to have a Permission entry
Note When Keys is a union of string literals, Record ensures every literal is present — great for exhaustive mappings. Record<string, T> is equivalent to { [key: string]: T } and does not enforce specific keys.
Recordkey value mapdictionary typeobject from unionexhaustive mapping
Exclude & Extract
syntax
type Result = Exclude<UnionType, ExcludedMembers>;
type Result = Extract<UnionType, ExtractedMembers>;
example
type AllEvents = "click" | "scroll" | "keydown" | "keyup" | "resize";
type KeyboardEvents = Extract<AllEvents, "keydown" | "keyup">;
// "keydown" | "keyup"type NonKeyboardEvents = Exclude<AllEvents, "keydown" | "keyup">;
// "click" | "scroll" | "resize"// Works with complex types tootype OnlyStrings = Extract<string | number | boolean, string>;
// string
output
// Exclude removes members; Extract keeps matching members
Note These work on union members, not object properties. To remove properties from an object, use Omit. Exclude and Extract filter which union members match the condition using distributive conditional types under the hood.
ExcludeExtractfilter unionremove from unionkeep matching types
// NonNullable removes null and undefined from a union
Note NonNullable is shorthand for Exclude<T, null | undefined>. It is commonly used after runtime null checks to assert the cleaned-up type. Pairs well with strictNullChecks.
// ReturnType extracts what the function returns; Parameters extracts the argument tuple
Note Both require typeof when used with a concrete function (not a type). For class constructors, use ConstructorParameters<typeof ClassName>. ReturnType is invaluable for inferring types from existing functions without duplicating definitions.
ReturnTypeParametersfunction return typeextract parametersinfer from function
Awaited<T>
syntax
type Result = Awaited<PromiseType>;
example
type PromisedUser = Promise<{ id: string; name: string }>;
type ResolvedUser = Awaited<PromisedUser>;
// { id: string; name: string }// Handles nested promisestype DeepPromise = Promise<Promise<Promise<number>>>;
type DeepResolved = Awaited<DeepPromise>;
// number// Practical: extract resolved type from async functionasyncfunction loadConfig() {
return { apiUrl: "https://api.example.com", timeout: 5000 };
}
type Config = Awaited<ReturnType<typeof loadConfig>>;
output
// Awaited recursively unwraps all layers of Promise
Note Added in TS 4.5. Awaited recursively unwraps nested Promises until it hits a non-Promise type. Before Awaited existed, developers had to write custom recursive unwrap types. Works with any thenable, not just native Promises.
Awaitedunwrap promisepromise result typeasync return typeresolve promise type
enum HttpStatus {
Ok = 200,
Created = 201,
BadRequest = 400,
Unauthorized = 401,
NotFound = 404,
ServerError = 500,
}
function isSuccessful(status: HttpStatus): boolean {
return status >= 200 && status < 300;
}
console.log(isSuccessful(HttpStatus.Ok)); // true
console.log(HttpStatus.NotFound); // 404
output
// true
// 404
Note Numeric enums auto-increment from the last explicit value. If no values are assigned, they start at 0. Beware: TypeScript allows assigning ANY number to a numeric enum variable, not just declared members — this is a known type safety gap.
numeric enumenum with numbersauto increment enumHTTP status enum
String Enums
syntax
enum Name {
Member = "value",
}
example
enum LogLevel {
Debug = "DEBUG",
Info = "INFO",
Warn = "WARN",
Error = "ERROR",
Fatal = "FATAL",
}
function log(level: LogLevel, message: string) {
console.log(`[${level}] ${message}`);
}
log(LogLevel.Error, "Database connection lost");
// log("ERROR", "..."); // Error: '"ERROR"' is not assignable to 'LogLevel'
output
// [ERROR] Database connection lost
Note String enums do NOT auto-increment — every member needs an explicit string value. They are more type-safe than numeric enums because you cannot accidentally pass an arbitrary string. The downside: you cannot pass the raw string value, only the enum member.
string enumenum with stringsnamed constantsenum values
const Enums
syntax
constenum Name {
Member = value,
}
example
constenum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT",
}
const playerDirection = Direction.Up;
// Compiles to: const playerDirection = "UP";// No Direction object exists at runtime
output
// const enums are completely erased — values are inlined at compile time
Note const enums produce zero runtime JavaScript — member accesses are replaced with literal values. However, they have compatibility issues: they cannot be used with --isolatedModules (Babel, esbuild, SWC), and they do not work across declaration files in some setups. Many teams ban them in favor of plain unions.
enum Permission {
Read = "READ",
Write = "WRITE",
Admin = "ADMIN",
}
// Each member is its own typefunction grantAdmin(userId: string, level: Permission.Admin) {
console.log(`Granting admin to ${userId}`);
}
grantAdmin("usr_1", Permission.Admin); // OK// grantAdmin("usr_1", Permission.Read); // Error: not assignable to Permission.Admin
output
// Individual enum members can be used as specific types
Note Each enum member is a subtype of the enum. You can use individual members in type positions to restrict parameters to a specific enum value. This is more useful with string enums since numeric enum members have the wider number type.
enum member typespecific enum valueenum subtypenarrow enum
Reverse Mapping (Numeric Enums)
syntax
EnumName[numericValue] // returns the member name as string
Note Reverse mapping ONLY works with numeric enums — string enums do not generate reverse mappings. The compiled JavaScript includes both name→value and value→name entries in the enum object. const enums do not support reverse mapping since they have no runtime object.
reverse mappingenum name from valuenumeric enum lookupenum to string
Union Types as Enum Alternatives
syntax
type Name = "value1" | "value2" | "value3";
example
// Instead of enum:type Theme = "light" | "dark" | "system";
// Object const pattern for when you need both runtime values and typesconst THEMES = {
Light: "light",
Dark: "dark",
System: "system",
} asconst;
type Theme2 = (typeof THEMES)[keyof typeof THEMES];
// "light" | "dark" | "system"function applyTheme(theme: Theme2) {
document.body.dataset.theme = theme;
}
output
// Union types achieve the same result without enum's quirks
Note Many TypeScript teams prefer string literal unions over enums for simplicity, full tree-shaking, and compatibility with --isolatedModules. The 'as const' object pattern gives you both runtime values (for iteration/lookup) and a derived type — the best of both worlds.
function formatValue(input: string | number | boolean): string {
if (typeof input === "string") {
return input.toUpperCase();
}
if (typeof input === "number") {
return input.toFixed(2);
}
return input ? "Yes" : "No";
}
output
// TypeScript narrows the type inside each branch automatically
Note typeof works for: "string", "number", "boolean", "undefined", "symbol", "bigint", "function", "object". Note: typeof null === "object" — this is a famous JavaScript quirk that TypeScript inherits.
typeoftype guardcheck type at runtimenarrow typetypeof narrowing
// instanceof narrows to the specific class, enabling access to class-specific properties
Note instanceof checks the prototype chain at runtime. It does not work with interfaces or type aliases (they have no runtime presence). It also fails across different realms (iframes) since each realm has its own constructor.
instanceofclass type guardcheck class typeerror handling narrowing
// 'in' narrows based on the presence of a property
Note The 'in' operator checks if a property name exists on an object at runtime. TypeScript uses this to narrow union types. The property name must be a string literal for narrowing to work. Works well when union members have distinct unique properties.
in operatorproperty checknarrow by propertyhas property guard
Custom Type Guard Functions
syntax
function isType(value: ParamType): value is TargetType {
return /* boolean check */;
}
example
interface Fish {
swim: () => void;
habitat: "water";
}
interface Bird {
fly: () => void;
habitat: "air";
}
function isFish(creature: Fish | Bird): creature is Fish {
return creature.habitat === "water";
}
function move(creature: Fish | Bird) {
if (isFish(creature)) {
creature.swim(); // TypeScript knows this is Fish
} else {
creature.fly(); // TypeScript knows this is Bird
}
}
output
// Custom predicates with 'is' return type enable reusable narrowing
Note The 'value is Type' return annotation is a type predicate — it tells TypeScript to narrow the variable when the function returns true. The compiler trusts your implementation; if your check is wrong, you get silent type errors at runtime. Always keep the check accurate.
custom type guardtype predicateis keyworduser defined guardreusable narrowing
Discriminated Unions
syntax
interface A { kind: "a"; ... }
interface B { kind: "b"; ... }
type Union = A | B;
example
interface LoadingState {
status: "loading";
}
interface SuccessState {
status: "success";
data: string[];
}
interface ErrorState {
status: "error";
errorMessage: string;
}
type RequestState = LoadingState | SuccessState | ErrorState;
function renderState(state: RequestState): string {
switch (state.status) {
case"loading":
return"Loading...";
case"success":
return`Got ${state.data.length} items`; // data is availablecase"error":
return`Error: ${state.errorMessage}`; // errorMessage is available
}
}
output
// Each case automatically narrows to the correct interface
Note A discriminated union has a common literal-typed property (the 'discriminant') shared across all members. TypeScript narrows exhaustively in switch/if on that property. This is the recommended pattern for modeling states, events, and message types.
function assert(value: unknown): asserts value is Type {
if (!check) thrownew Error(...);
}
example
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
thrownew TypeError(`Expected string, got ${typeof value}`);
}
}
function assertDefined<T>(value: T | null | undefined): asserts value is T {
if (value == null) {
thrownew Error("Value must not be null or undefined");
}
}
function processInput(data: unknown) {
assertIsString(data);
// After this line, data is typed as string
console.log(data.toUpperCase());
}
output
// After the assertion call, TypeScript narrows for the rest of the scope
Note Assertion functions narrow by throwing on failure rather than returning a boolean. They use 'asserts value is Type' (or just 'asserts value' for truthiness). Unlike type predicates, they affect the current scope rather than an if branch. Must throw — not return false.
assertion functionasserts keywordthrow to narrowruntime assertionassert type
Truthiness Narrowing
syntax
if (value) { ... } // narrows out null, undefined, 0, "", false
example
function printLength(input: string | null | undefined) {
if (input) {
// input is narrowed to string (null and undefined excluded)
console.log(`Length: ${input.length}`);
} else {
console.log("No input provided");
}
}
// Combining with logical operatorsfunction getDisplayName(first?: string, last?: string): string {
return (first && last) ? `${first} ${last}` : first ?? last ?? "Anonymous";
}
output
// Truthiness checks exclude null, undefined, and falsy values
Note Truthiness narrowing also excludes 0, empty string, and false — which may not be what you want. If 0 or "" are valid values, check explicitly for null/undefined instead: if (value != null) or if (value !== undefined).
type Result = T extends Condition ? TrueType : FalseType;
example
type IsString<T> = T extendsstring ? "yes" : "no";
type A = IsString<string>; // "yes"type B = IsString<number>; // "no"type C = IsString<"hello">; // "yes"// Practical: flatten one level of arraytype Flatten<T> = T extends Array<infer Item> ? Item : T;
type Str = Flatten<string[]>; // stringtype Num = Flatten<number>; // number (not an array, returned as-is)
output
// Conditional types branch on whether T matches a shape
Note Conditional types distribute over naked union type parameters: IsString<string | number> becomes IsString<string> | IsString<number> = "yes" | "no". Wrap in tuple to prevent distribution: [T] extends [string] ? ... : ...
conditional typetype branchingtype-level ifextends conditionaldistribute union
Mapped Types
syntax
type Result = {
[K in keyof T]: NewType;
};
example
interface UserProfile {
name: string;
email: string;
age: number;
}
// Make every property a getter functiontype Getters<T> = {
[K in keyof T as`get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<UserProfile>;
// {// getName: () => string;// getEmail: () => string;// getAge: () => number;// }// Make all properties mutable (remove readonly)type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
output
// Mapped types transform every property of an existing type
Note Key remapping with 'as' (TS 4.1+) enables renaming keys. Filter out keys by mapping to never: [K in keyof T as T[K] extends Function ? never : K]. The +/- modifiers add or remove readonly and optional (?) markers.
mapped typetransform propertieskey remappingmodify all propertiesiterate keys
infer Keyword
syntax
type Result = T extends Pattern<infer U> ? U : Fallback;
example
// Extract the resolved type from a Promisetype UnwrapPromise<T> = T extends Promise<infer R> ? R : T;
type A = UnwrapPromise<Promise<string>>; // stringtype B = UnwrapPromise<number>; // number// Extract function first argumenttype FirstArg<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
type FA = FirstArg<(name: string, age: number) => void>; // string// Extract array element typetype ArrayItem<T> = T extends readonly (infer E)[] ? E : never;
type Item = ArrayItem<readonly ["a", "b", "c"]>; // "a" | "b" | "c"
output
// infer declares a type variable that TypeScript figures out from context
Note infer can only be used inside the extends clause of a conditional type. It captures whatever type fits in that position. Multiple infer clauses in the same conditional are allowed. In union position, infer produces a union; in intersection position (like function params), it produces an intersection.
infer keywordextract typepattern matching typecapture genericunwrap type
Recursive Types
syntax
type TreeNode<T> = {
value: T;
children: TreeNode<T>[];
};
example
// JSON-compatible typetype JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
// Deep Readonlytype DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
interface NestedConfig {
db: { host: string; port: number; ssl: { enabled: boolean } };
}
type FrozenConfig = DeepReadonly<NestedConfig>;
// All levels are readonly — db.ssl.enabled cannot be reassigned
output
// Recursive types reference themselves in their definition
Note TypeScript handles direct recursive type aliases since TS 3.7. Earlier versions needed interface workarounds. Be careful with deeply recursive conditional types — the compiler has a recursion depth limit (around 50 levels by default) and will error if exceeded.
recursive typenested typedeep typetree typeJSON typeself-referencing type
Branded / Nominal Types
syntax
type Brand<T, B> = T & { readonly __brand: B };
example
type USD = number & { readonly __brand: "USD" };
type EUR = number & { readonly __brand: "EUR" };
function usd(amount: number): USD {
return amount as USD;
}
function eur(amount: number): EUR {
return amount as EUR;
}
function chargeUSD(amount: USD) {
console.log(`Charging $${amount}`);
}
const price = usd(29.99);
chargeUSD(price); // OK// chargeUSD(eur(25.00)); // Error: EUR is not assignable to USD// chargeUSD(29.99); // Error: number is not assignable to USD
Note TypeScript uses structural typing — two types with the same shape are interchangeable. Branded types add a phantom property to create nominal (name-based) distinction. The __brand property never exists at runtime; it is purely a compile-time discriminator. Use this for user IDs, currency, coordinates, etc.
// Template literals combined with infer enable string parsing at the type level
Note Template literal types combined with conditional types and infer enable powerful string parsing and transformation at compile time. This is the foundation for type-safe routing, ORM query builders, and CSS-in-JS libraries.
type ColorMap = Record<string, string | number[]>;
// Without satisfies: loses specific types// const colors: ColorMap = { ... } → all values are string | number[]// With satisfies: validates shape AND keeps specific typesconst colors = {
red: "#ff0000",
green: [0, 255, 0],
blue: "#0000ff",
} satisfies ColorMap;
colors.red.toUpperCase(); // OK — TypeScript knows red is string
colors.green.map(c => c); // OK — TypeScript knows green is number[]// colors.red.map(c => c); // Error — string has no .map
output
// satisfies validates compatibility without widening the inferred type
Note Added in TS 4.9. satisfies checks that a value matches a type without changing the inferred type. This gives you the best of both worlds: validation that the shape is correct, plus precise inference of each property. Use it instead of explicit type annotations when you want both safety and specificity.
satisfiesvalidate typecheck without wideningsatisfies operatortype validation
keyof & typeof Operators
syntax
type Keys = keyof Type;
type ValueType = typeof runtimeValue;
// keyof extracts property names; typeof extracts the type of a value
Note keyof works on types; typeof works on values. They are often combined: keyof typeof myObject gives you the union of property names of a runtime object. typeof only works with variables and properties, not with arbitrary expressions like function calls.
keyoftypeofproperty names typeextract keysvalue to typeobject keys type
// public = anywhere, private = same class only, protected = class + subclasses
Note TypeScript access modifiers are compile-time only — at runtime, everything is accessible. For true runtime privacy, use JavaScript's native #private fields (e.g., #balance). The public modifier is the default and can be omitted.
// Adding an access modifier to a constructor param auto-declares and assigns the property
Note Parameter properties reduce boilerplate by combining declaration, constructor parameter, and assignment into one. You can combine them with readonly. Regular (unmodified) parameters are not turned into properties — only those prefixed with public, private, protected, or readonly.
parameter propertyconstructor shorthandauto assign constructorshorthand class property
Abstract Classes
syntax
abstract class Name {
abstract method(): Type;
concreteMethod(): Type { ... }
}
example
abstract class Shape {
abstract area(): number;
abstract perimeter(): number;
describe(): string {
return`Area: ${this.area().toFixed(2)}, Perimeter: ${this.perimeter().toFixed(2)}`;
}
}
class Circle extends Shape {
constructor(private radius: number) {
super();
}
area(): number {
return Math.PI * this.radius ** 2;
}
perimeter(): number {
return2 * Math.PI * this.radius;
}
}
// const shape = new Shape(); // Error: Cannot instantiate abstract classconst circle = new Circle(5);
console.log(circle.describe());
output
// "Area: 78.54, Perimeter: 31.42"
Note Abstract classes cannot be instantiated directly. They can contain both abstract members (no implementation — subclasses must provide them) and concrete members (shared implementation). Use abstract classes when subclasses share behavior; use interfaces when they only share shape.
class Child extends Parent {
override method(): Type { ... }
}
example
class Transport {
getSpeed(): number {
return0;
}
describe(): string {
return`Speed: ${this.getSpeed()} km/h`;
}
}
class Train extends Transport {
override getSpeed(): number {
return300;
}
// override getSped(): number { // Error with noImplicitOverride:// return 300; // 'getSped' does not exist on base// }
}
output
// override keyword verifies the method actually exists on the parent class
Note Enable noImplicitOverride in tsconfig to require the override keyword on all overriding methods. This catches typos (overriding a method that does not exist on the parent) and refactoring bugs (parent method was renamed but child was not updated).
// Calling processOrder with ["ord_123"]
// Processing ord_123
Note TS 5.0 introduced TC39 standard decorators — these are different from the legacy experimental decorators. Standard decorators receive a context object instead of the old three-parameter signature. Set "experimentalDecorators": false (or omit it) to use the new standard. Legacy decorators are still available with "experimentalDecorators": true.
// A class can implement multiple interfaces, enforcing all their contracts
Note implements is purely a compile-time check — it does not inherit any code. Each property and method must be explicitly defined in the class. If the class already matches the interface shape, implements is optional but serves as documentation and catches regressions.
class implementsmultiple interfacesclass contractserializable patternimplement multiple
// .d.ts files provide type info without runtime code
Note Declaration files describe the shape of existing JavaScript code. They contain only type information — no executable code. TypeScript automatically picks up .d.ts files in your project. Most npm packages include their own or have a @types/package companion.
declare const name: Type;
declare function fn(): Type;
declare class Name { ... }
declare module "name" { ... }
example
// Tell TypeScript about a global that exists at runtime
declare const gtag: (
command: "config" | "event",
targetId: string,
params?: Record<string, unknown>
) => void;
// Tell TypeScript about a global class
declare class Stripe {
constructor(apiKey: string);
charges: {
create(params: { amount: number; currency: string }): Promise<unknown>;
};
}
gtag("event", "purchase", { value: 99.99 });
output
// declare tells TypeScript 'trust me, this exists at runtime'
Note declare is for things that already exist in the runtime environment but TypeScript cannot see (global scripts, CDN libraries, injected variables). It emits zero JavaScript. Using declare when the value does not actually exist leads to runtime crashes with no compile-time warning.
// types/untyped-libs.d.ts// Type an entire untyped npm package
declare module "color-hash" {
exportdefaultclass ColorHash {
constructor(options?: { lightness: number });
hex(input: string): string;
rgb(input: string): [number, number, number];
}
}
// Wildcard: type all .svg imports
declare module "*.svg" {
const content: string;
exportdefault content;
}
// Wildcard: type all .css modules
declare module "*.module.css" {
const classes: Record<string, string>;
exportdefault classes;
}
output
// Now: import ColorHash from 'color-hash'; // fully typed
Note Ambient module declarations let you type third-party packages that lack types, or describe non-JS imports (CSS, SVG, images). Wildcard modules (*.ext) match any import path ending with that extension. Place these in a .d.ts file included in your tsconfig.
ambient moduledeclare moduletype untyped packagewildcard modulecss module typesvg import type
Module Augmentation
syntax
// In a .ts file (not .d.ts)import"original-module";
declare module "original-module" {
interface ExistingInterface {
newProperty: Type;
}
}
example
// Augmenting Express Requestimport"express";
declare module "express" {
interface Request {
userId?: string;
sessionToken?: string;
permissions?: string[];
}
}
// Now in your middleware:import { Request, Response, NextFunction } from"express";
function authMiddleware(req: Request, res: Response, next: NextFunction) {
req.userId = "usr_123"; // No error — augmented
req.permissions = ["read"]; // No error — augmented
next();
}
output
// The augmented properties are available everywhere Request is used
Note Module augmentation extends existing module types without modifying the original package. The file performing augmentation must be a module itself (have at least one import/export). This only works for interfaces and namespaces — you cannot add new top-level exports through augmentation.
module augmentationextend module typesaugment expressadd property to typepatch types
/// <reference types="vite/client" />// Enables Vite-specific types like import.meta.env/// <reference lib="es2022" />// Includes ES2022 library types/// <reference path="./custom-globals.d.ts" />// Explicitly includes another declaration file
output
// Triple-slash directives are special comments that instruct the compiler
Note Triple-slash directives must appear at the very top of the file (before any code). They are mostly obsolete for modern projects — tsconfig's 'types', 'lib', and 'include' options do the same job. The main surviving use case is /// <reference types="..." /> in .d.ts files and Vite/framework setups.
// ES modules are the standard; namespaces are legacy
Note Namespaces (formerly 'internal modules') are largely obsolete. Modern TypeScript projects should use ES modules (import/export) exclusively. Namespaces still appear in declaration files for global libraries and in legacy codebases. Do not use namespace and module imports in the same file.
namespacenamespace vs moduleinternal modulelegacy namespaceorganize types
// strict: true enables ALL of these at once:// - strictNullChecks: null/undefined not assignable to other types// - strictFunctionTypes: contravariant function parameter checking// - strictBindCallApply: type-check bind, call, apply// - strictPropertyInitialization: class props must be initialized// - noImplicitAny: error on implicit any// - noImplicitThis: error on 'this' with implicit any type// - useUnknownInCatchVariables: catch variable is unknown, not any// - alwaysStrict: emit "use strict" in every file
output
// strict: true is the recommended baseline for all new projects
Note Always start new projects with strict: true. You can selectively disable individual checks if needed (e.g., "strictPropertyInitialization": false) while keeping the rest. Enabling strict on a large existing JS-to-TS migration can produce thousands of errors — enable checks incrementally in that case.
strict modetsconfig strictstrictNullChecksenable all checksrecommended config
// target: which JS version to emit// "ES5" — maximum compat (IE11), heavy downleveling// "ES2020" — good baseline for modern browsers// "ES2022" — class fields, top-level await, cause in Error// "ESNext" — latest; may change between TS versions// module: which module system to use// "commonjs" — Node.js require/exports// "es2022" — ES modules with top-level await// "esnext" — latest ES module features// "node16" / "nodenext" — Node.js ESM with .mjs/.cjs awareness// "preserve" — keep import/export as written (TS 5.4+)
output
// target controls syntax downleveling; module controls import/export format
Note For bundled apps (Vite, webpack), set target to your browser baseline and module to ESNext — the bundler handles the rest. For Node.js, use module: "node16" or "nodenext" which correctly resolves .mjs/.cjs and package.json "type" fields. The new "preserve" module option (TS 5.4+) emits imports exactly as written.
targetmoduleES versionmodule systemcommonjs vs esmtsconfig target
// Path aliases shorten deep relative imports like ../../../../utils
Note paths only affects TypeScript's type resolution — it does NOT rewrite imports in emitted JS. Your bundler (Vite, webpack) or runtime (ts-node, tsx) needs matching alias config. For Node.js, package.json "imports" with subpath patterns is the modern alternative that works without bundler config.
// noEmit: true — only type-check, don't produce .js files// Used when a bundler (Vite, esbuild, SWC) handles transpilation
{
"compilerOptions": {
"noEmit": true// tsc is just the type checker
}
}
// declaration: true — generate .d.ts files alongside .js// Used for libraries that need to ship type definitions
{
"compilerOptions": {
"declaration": true,
"declarationDir": "./dist/types",
"emitDeclarationOnly": true// only .d.ts, no .js
}
}
output
// noEmit for apps (bundler compiles); declaration for libraries (ship types)
Note Most modern app setups use noEmit: true because tools like Vite, esbuild, and SWC transpile much faster than tsc. Library authors use declaration: true (often with emitDeclarationOnly) to generate .d.ts files while letting another tool handle .js output.
// isolatedModules: true// Ensures each file can be transpiled independently// Required for: Babel, esbuild, SWC, Vite// Disallows: const enums across files, namespace merging across files// verbatimModuleSyntax: true (TS 5.0+, replaces isolatedModules)// Forces you to use 'import type' for type-only importsimporttype { User } from"./models"; // erased at runtimeimport { processUser } from"./handlers"; // kept at runtime// import { User } from "./models";// Error with verbatimModuleSyntax: 'User' is a type and must use 'import type'
output
// Enforces clean separation of type imports from value imports
Note verbatimModuleSyntax (TS 5.0+) supersedes both isolatedModules and the older importsNotUsedAsValues. It makes the distinction between type and value imports explicit and mandatory. This produces cleaner output and avoids side-effect confusion. Recommended for all new projects.
// tsc --build only recompiles packages that changed
Note Project references enable incremental builds in monorepos. Each sub-project needs "composite": true. Build with tsc --build (or tsc -b) which understands dependency order. This dramatically speeds up type-checking in large codebases by skipping unchanged packages.
// A solid starting tsconfig for a modern bundled web app
Note moduleResolution: "bundler" (TS 5.0+) matches how Vite/webpack/esbuild resolve modules — it supports exports maps, .ts extensions in imports, and does not require file extensions. skipLibCheck: true speeds up compilation by not checking node_modules .d.ts files (only your code is checked).
// Full autocomplete on event names, parameter types, and callback signatures
Note This pattern maps event names to callback signatures. Parameters<T[K]> extracts the argument tuple so emit() is fully typed. This is the foundation used by libraries like mitt, EventEmitter3, and socket.io.
typed event emitterevent bustype safe eventspub sub typedemit pattern
Type-Safe Builder Pattern
syntax
class Builder<T> {
set<K extends keyof T>(key: K, value: T[K]): this { ... }
build(): T { ... }
}
example
interface EmailMessage {
to: string;
subject: string;
body: string;
cc?: string[];
priority?: "low" | "normal" | "high";
}
class EmailBuilder {
private message: Partial<EmailMessage> = {};
to(address: string): this {
this.message.to = address;
returnthis;
}
subject(text: string): this {
this.message.subject = text;
returnthis;
}
body(html: string): this {
this.message.body = html;
returnthis;
}
cc(addresses: string[]): this {
this.message.cc = addresses;
returnthis;
}
priority(level: EmailMessage["priority"]): this {
this.message.priority = level;
returnthis;
}
build(): EmailMessage {
if (!this.message.to || !this.message.subject || !this.message.body) {
thrownew Error("to, subject, and body are required");
}
returnthis.message as EmailMessage;
}
}
const email = new EmailBuilder()
.to("[email protected]")
.subject("Deploy complete")
.body("<p>All checks passed.</p>")
.priority("high")
.build();
output
// Fluent API with full type safety and autocomplete at each step
Note Returning 'this' instead of the class name enables proper chaining even in subclasses. For compile-time enforcement of required fields (instead of runtime throws), use a step builder pattern with generics that track which fields have been set.
function assertNever(value: never): never {
thrownew Error(`Unexpected: ${value}`);
}
example
type PaymentMethod = "card" | "bank" | "crypto" | "paypal";
function processPayment(method: PaymentMethod): string {
switch (method) {
case"card":
return"Processing card payment";
case"bank":
return"Processing bank transfer";
case"crypto":
return"Processing crypto payment";
case"paypal":
return"Processing PayPal";
default:
// If a new member is added to PaymentMethod,// this line causes a compile errorconst _exhaustive: never = method;
thrownew Error(`Unknown method: ${_exhaustive}`);
}
}
output
// Adding a fifth payment method causes a compile error at the never assignment
Note The never trick ensures you handle every union member. When a new member is added, TypeScript sees it is not handled and cannot assign it to never. This is critical for state machines, reducers, and any logic that must cover all cases. Some teams extract this into a shared assertNever utility function.
exhaustive switchassertNeverhandle all casesmissing case errorexhaustive check
const Assertions
syntax
const value = expression asconst;
example
// Without as const: types are widenedconst config = { apiUrl: "https://api.example.com", retries: 3 };
// { apiUrl: string; retries: number }// With as const: literal types + readonlyconst frozenConfig = {
apiUrl: "https://api.example.com",
retries: 3,
features: ["search", "export"],
} asconst;
// { readonly apiUrl: "https://api.example.com"; readonly retries: 3; readonly features: readonly ["search", "export"] }// Deriving a union type from const valuesconst ROLES = ["admin", "editor", "viewer"] asconst;
type Role = (typeof ROLES)[number];
// "admin" | "editor" | "viewer"
output
// as const freezes the type to its most specific literal form
Note as const makes all properties readonly and infers the narrowest possible literal types. It applies recursively to nested objects and arrays. This is the idiomatic way to derive string literal unions from runtime arrays, replacing enums in many cases.
as constconst assertionliteral inferencereadonly objectderive union from array
Result Type Pattern
syntax
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
example
type Result<T, E = string> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseAge(input: string): Result<number> {
const num = parseInt(input, 10);
if (isNaN(num)) return { ok: false, error: "Not a valid number" };
if (num < 0 || num > 150) return { ok: false, error: "Age out of range" };
return { ok: true, value: num };
}
const result = parseAge("25");
if (result.ok) {
console.log(`Age: ${result.value}`); // narrowed to { ok: true; value: number }
} else {
console.log(`Invalid: ${result.error}`); // narrowed to { ok: false; error: string }
}
output
// Discriminated union forces callers to handle both success and failure
Note The Result pattern replaces throwing exceptions with explicit return types. Callers cannot forget to handle the error case because TypeScript requires narrowing before accessing .value or .error. This is inspired by Rust's Result<T, E> type.
result typeerror handling patterneither typesuccess failureno throw pattern
Type-Safe Runtime Validation
syntax
// Using infer with validation librariestype Inferred = z.infer<typeof schema>;
example
// Zod-style schema → derived type (no duplication)import { z } from"zod";
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
role: z.enum(["admin", "user", "guest"]),
});
// Derive the TypeScript type from the schematype User = z.infer<typeof UserSchema>;
// { id: string; name: string; email: string; age?: number; role: "admin" | "user" | "guest" }function createUser(raw: unknown): User {
return UserSchema.parse(raw); // throws if invalid
}
output
// Single schema defines both runtime validation and compile-time type
Note Libraries like Zod, Valibot, and ArkType let you define a schema once and derive the TypeScript type from it. This eliminates the common problem of types and validation getting out of sync. z.infer<typeof schema> is a powerful pattern that keeps your types as the single source of truth.
zodruntime validationschema typeinfer from schematype safe parsingvalidate unknown
Type-Safe Action / Reducer Pattern
syntax
type Action = { type: "name"; payload: Type } | ...;
function reducer(state: State, action: Action): State { ... }
// Each case narrows the action type, giving access to case-specific fields
Note This is the standard pattern for Redux, useReducer, and state machines. The 'type' property serves as the discriminant. TypeScript narrows automatically in each case, so action.amount is only available in increment/decrement cases. Add a default never check for exhaustiveness.
reducer patternaction typeredux typeuseReducer typedstate machinedispatch type
// BAD: any everywherefunction process(data: any): any { ... }
// GOOD: use proper typesfunction process(data: unknown): Result { ... }
example
// BAD — any silently disables all type safetyfunction parseConfig(raw: any) {
return raw.database.host; // No error even if raw is null
}
// GOOD — unknown forces runtime checksfunction parseConfig(raw: unknown): { host: string; port: number } {
if (
typeof raw === "object" && raw !== null &&
"database"in raw &&
typeof (raw as any).database?.host === "string"
) {
const db = (raw as { database: { host: string; port: number } }).database;
return db;
}
thrownew Error("Invalid config shape");
}
// BEST — use a validation library// const config = ConfigSchema.parse(raw);
output
// any: zero safety. unknown: safe. Validated schema: safest.
Note any is infectious — any value that touches any also becomes any, spreading unsafety silently through your codebase. Use eslint rule @typescript-eslint/no-explicit-any to catch it. Legitimate uses: interfacing with very dynamic libraries, or as a temporary measure during migration.
any abuseavoid anyunknown vs anytype safetyno explicit any
Unnecessary / Incorrect Generics
syntax
// BAD: generic that adds no valuefunction bad<T extendsstring>(s: T): T { return s; }
// GOOD: just use the concrete typefunction good(s: string): string { return s; }
example
// BAD — T is unused, just makes the signature confusingfunction logValue<T>(value: T): void {
console.log(value);
}
// GOOD — no generic neededfunction logValue(value: unknown): void {
console.log(value);
}
// BAD — generic is always forced to one type anywayfunction fetchUser<T extends User>(id: string): Promise<T> { ... }
// GOOD — just return the concrete typefunction fetchUser(id: string): Promise<User> { ... }
// GOOD use of generics — T connects input to outputfunction pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map(item => item[key]);
}
output
// Use generics when T appears in at least two positions (input→output link)
Note A generic type parameter is useful when it connects two or more positions: input to output, or one parameter to another. If T appears in only one spot, a concrete type (or unknown) is simpler and clearer. Over-genericizing is a common code smell in TypeScript.
unnecessary genericwrong genericwhen to use genericsgeneric code smellsimplify generics
Enum Pitfalls
syntax
// Numeric enum accepts any numberenum Status { Active = 0, Inactive = 1 }
const s: Status = 999; // No error!
example
// PITFALL 1: Numeric enums accept any numberenum Priority { Low, Medium, High }
const p: Priority = 42; // Compiles fine! No type error.// PITFALL 2: const enums break with isolatedModulesconstenum Dir { Up, Down } // Error with isolatedModules// PITFALL 3: Enums are not tree-shakeableenum Color { Red, Green, Blue }
// Compiles to a runtime object — bundlers cannot remove unused members// ALTERNATIVE: Use string literal unionstype Priority = "low" | "medium" | "high";
const p: Priority = "low";
// p = "whatever"; // Error — properly type-safe
output
// String literal unions are simpler, safer, and more compatible
Note Numeric enums have a type safety gap — any number is assignable. String enums are safer but add runtime overhead. const enums are erased but break with Babel/esbuild/SWC. For most cases, string literal unions (optionally with an as const object for runtime values) are the pragmatic choice.
enum pitfallenum problemnumeric enum bugenum vs unionenum tree shaking
Type Assertion vs Type Guard
syntax
// DANGEROUS: assertion (no runtime check)const user = data as User;
// SAFE: type guard (runtime check)if (isUser(data)) { /* data is User */ }
example
interface User {
id: string;
name: string;
email: string;
}
// BAD: assertion — no runtime check, crashes if wrongconst user = JSON.parse(rawJson) as User;
console.log(user.email.toUpperCase()); // Runtime crash if email is missing// GOOD: type guard — verifies at runtimefunction isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id"in value &&
"name"in value &&
"email"in value
);
}
const data = JSON.parse(rawJson);
if (isUser(data)) {
console.log(data.email.toUpperCase()); // Safe
} else {
console.error("Invalid user data");
}
output
// Assertions trust you blindly; guards verify at runtime
Note Type assertions (as Type) are a compile-time lie — they produce zero runtime code. Type guards produce actual runtime checks. Always prefer guards for external data (API responses, user input, file reads). Assertions are acceptable for values you genuinely control (e.g., DOM elements you know exist).
assertion vs guardas vs type guardruntime checksafe castvalidate type
// These all cause implicit any errors with noImplicitAny:// Function parameters without annotationsfunction greet(name) { // Error: Parameter 'name' implicitly has 'any' typereturn`Hello, ${name}`;
}
// Destructured parametersfunction render({ title, body }) { // Error on bothreturn`<h1>${title}</h1><p>${body}</p>`;
}
// Fix: always annotate function parametersfunction greet(name: string) {
return`Hello, ${name}`;
}
function render({ title, body }: { title: string; body: string }) {
return`<h1>${title}</h1><p>${body}</p>`;
}
output
// noImplicitAny (enabled by strict: true) catches unannotated parameters
Note noImplicitAny is part of strict mode and catches function parameters that would silently become any. This is one of the most important checks — without it, many functions quietly lose all type safety. Callback parameters in typed contexts (like .map(), .filter()) are inferred and do not trigger this error.
implicit anynoImplicitAnymissing annotationparameter anystrict any
Trusting readonly at Runtime
syntax
// readonly is compile-time onlyconst arr: readonly number[] = [1, 2, 3];
(arr asnumber[]).push(4); // No runtime protection
example
interface Config {
readonly apiKey: string;
readonly maxRetries: number;
}
const config: Config = { apiKey: "secret", maxRetries: 3 };
// TypeScript prevents this:// config.apiKey = "changed"; // Error// But at runtime, nothing stops this:
(config as any).apiKey = "changed"; // Works at runtime!
console.log(config.apiKey); // "changed"// For real immutability, use Object.freeze:const safeConfig = Object.freeze({ apiKey: "secret", maxRetries: 3 });
// safeConfig.apiKey = "x"; // Runtime TypeError + compile error
output
// readonly is erased at runtime — use Object.freeze for true immutability
Note readonly, Readonly<T>, and ReadonlyArray are all compile-time only. They are stripped during compilation and provide zero runtime enforcement. Code that bypasses the type system (any casts, JavaScript interop) can still mutate. For sensitive data, combine readonly with Object.freeze or structuredClone.
readonly runtimeimmutability mistakeObject.freezereadonly limitationcompile time only
Structural Typing Surprises
syntax
// TypeScript uses structural (shape) typing, not nominal (name) typing
example
interface Cat {
name: string;
purr(): void;
}
interface Robot {
name: string;
purr(): void;
}
// These are the SAME type to TypeScript!const robot: Robot = { name: "RoboCat", purr() { console.log("bzzz"); } };
const cat: Cat = robot; // No error — shapes match// Excess property checking only works on object literals:const direct: Cat = {
name: "Whiskers",
purr() {},
// batteries: true, // Error: Object literal may only specify known properties
};
// But NOT on variables:const obj = { name: "X", purr() {}, batteries: true };
const sneaky: Cat = obj; // No error — extra properties allowed from variables
output
// Two types with the same shape are interchangeable, regardless of name
Note Structural typing means TypeScript cares about shape, not name. Excess property checking only catches extra properties on direct object literals — not on variables. This often surprises developers from C#/Java backgrounds. Use branded types if you need nominal distinction.