Go beyond string and number. This page covers everything from generics and conditional types to the compiler internals and tsconfig options that actually matter. After this, you'll never be confused by a TypeScript error again.
infer Keyword
8. Decorators
9. Modules, Namespaces & Declaration Files
10. tsconfig.json Deep Dive
11. The TypeScript Compiler
12. Real-World Patterns & Gotchas
TypeScript uses a structural type system (duck typing), not a nominal one. Two types are compatible if their shapes match, regardless of what they're named.
TypeScript
interface Dog { name: string; bark(): void }
interface Pet { name: string }
const d: Dog = { name: "Rex", bark() { console.log("woof") } };
const p: Pet = d; // ✅ OK -- Dog has everything Pet needs
Dog and Pet would need an explicit extends/implements relationship. In TS, shape is all that matters.
TS narrows types through control flow analysis. After a check, the compiler knows the type is more specific.
TypeScript
function process(val: string | number) {
if (typeof val === "string") {
// TS knows val is string here
console.log(val.toUpperCase());
} else {
// TS knows val is number here
console.log(val.toFixed(2));
}
}
The most powerful pattern for modeling domain types. Add a literal "tag" field that TS uses to narrow.
TypeScript
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; w: number; h: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.radius ** 2;
case "rect": return s.w * s.h;
}
}
Generics let you write code that works with any type while keeping type safety. Think of <T> as a type parameter -- a variable, but for types.
TypeScript
// Without generics -- loses type info
function identity(val: any): any { return val; }
// With generics -- T flows through
function identity<T>(val: T): T { return val; }
const num = identity(42); // type: number (inferred)
const str = identity("hi"); // type: string (inferred)
Use extends to constrain what T can be.
TypeScript
function getLength<T extends { length: number }>(item: T): number {
return item.length;
}
getLength("hello"); // ✅ string has .length
getLength([1,2,3]); // ✅ array has .length
getLength(42); // ❌ number has no .length
TypeScript
function merge<A, B>(a: A, b: B): A & B {
return { ...a, ...b };
}
const result = merge({ name: "Sean" }, { age: 25 });
// type: { name: string } & { age: number }
TypeScript
interface Repository<T> {
getById(id: string): Promise<T | null>;
save(entity: T): Promise<void>;
}
class UserRepo implements Repository<User> {
async getById(id: string) { /* ... */ }
async save(user: User) { /* ... */ }
}
TypeScript
interface ApiResponse<T = unknown> {
data: T;
status: number;
}
const res: ApiResponse = { data: "anything", status: 200 };
const typed: ApiResponse<User> = { data: user, status: 200 };
Conditional types are if/else for types. The syntax mirrors the ternary operator.
TypeScript
// Syntax: T extends U ? TrueType : FalseType
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<"hello">; // "yes"
type B = IsString<42>; // "no"
When T is a union, the conditional distributes over each member individually.
TypeScript
type ToArray<T> = T extends any ? T[] : never;
type R = ToArray<string | number>;
// = string[] | number[] (NOT (string | number)[])
// To prevent distribution, wrap in tuple:
type ToArrayND<T> = [T] extends [any] ? T[] : never;
type R2 = ToArrayND<string | number>;
// = (string | number)[]
TypeScript
type TypeName<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends Function ? "function" :
"object";
type T1 = TypeName<string>; // "string"
type T2 = TypeName<() => void>; // "function"
Mapped types transform every property of an existing type. They iterate over keys with in.
TypeScript
// Make every property optional
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
// Make every property readonly
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
asTypeScript
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface User { name: string; age: number }
type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number }
TypeScript
// Keep only string properties
type StringProps<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type R = StringProps<{ name: string; age: number; email: string }>;
// { name: string; email: string }
TS ships with utility types built from generics + conditionals + mapped types. Here's how they work under the hood.
TypeScript
// Partial<T> -- make all props optional
type Partial<T> = { [K in keyof T]?: T[K] };
// Required<T> -- make all props required
type Required<T> = { [K in keyof T]-?: T[K] };
// Readonly<T> -- make all props readonly
type Readonly<T> = { readonly [K in keyof T]: T[K] };
// Pick<T, K> -- select specific keys
type Pick<T, K extends keyof T> = { [P in K]: T[P] };
// Omit<T, K> -- remove specific keys
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
// Record<K, V> -- build an object type
type Record<K extends keyof any, V> = { [P in K]: V };
// Exclude<T, U> -- remove types from a union
type Exclude<T, U> = T extends U ? never : T;
// Extract<T, U> -- keep types from a union
type Extract<T, U> = T extends U ? T : never;
// NonNullable<T> -- remove null and undefined
type NonNullable<T> = T extends null | undefined ? never : T;
// ReturnType<T> -- extract function return type
type ReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : any;
// Parameters<T> -- extract function params as tuple
type Parameters<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never;
TypeScript
interface User { id: string; name: string; email: string; role: string }
type CreateUserInput = Omit<User, "id">;
type UpdateUserInput = Partial<Pick<User, "name" | "email">>;
type UserLookup = Record<string, User>;
TypeScript
interface Config { host: string; port: number; debug: boolean }
type ConfigKey = keyof Config; // "host" | "port" | "debug"
function getConfig<K extends keyof Config>(key: K): Config[K] {
return config[key];
}
const h = getConfig("host"); // type: string
const p = getConfig("port"); // type: number
TypeScript
const defaults = { host: "localhost", port: 3000, debug: false };
type Config = typeof defaults;
// { host: string; port: number; debug: boolean }
// Combine with keyof:
type ConfigKey = keyof typeof defaults;
// "host" | "port" | "debug"
TypeScript
interface User { name: string; address: { city: string; zip: string } }
type Address = User["address"]; // { city: string; zip: string }
type City = User["address"]["city"]; // string
// Index into arrays:
const roles = ["admin", "user", "guest"] as const;
type Role = (typeof roles)[number]; // "admin" | "user" | "guest"
infer Keywordinfer lets you extract types from within other types inside conditional type branches. Think of it as pattern matching for types.
TypeScript
// Extract the return type of a function
type MyReturnType<T> =
T extends (...args: any[]) => infer R ? R : never;
type R1 = MyReturnType<() => string>; // string
type R2 = MyReturnType<(x: number) => User>; // User
TypeScript
// Extract element type from array
type ElementOf<T> = T extends (infer E)[] ? E : never;
type E = ElementOf<string[]>; // string
// Extract Promise inner type
type Awaited<T> = T extends Promise<infer U> ? U : T;
type A = Awaited<Promise<string>>; // string
// Extract first arg of a function
type FirstArg<T> =
T extends (first: infer F, ...rest: any[]) => any ? F : never;
type F = FirstArg<(name: string, age: number) => void>; // string
// Extract the type from a class constructor
type InstanceOf<T> =
T extends new (...args: any[]) => infer I ? I : never;
infer can only be used inside the extends clause of a conditional type. Think of it as "capture whatever type appears in this position."
Decorators are functions that modify classes, methods, or properties at definition time. They're used heavily in frameworks like NestJS, TypeORM, and Angular.
"experimentalDecorators": true in tsconfig. The new standard does NOT require that flag.
TypeScript
// A decorator is just a function that receives the target
function Sealed(target: Function) {
Object.seal(target);
Object.seal(target.prototype);
}
@Sealed
class UserService {
getUser() { /* ... */ }
}
TypeScript
function Log(target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function(...args: any[]) {
console.log(`Calling ${key} with`, args);
return original.apply(this, args);
};
}
class MathService {
@Log
add(a: number, b: number) { return a + b; }
}
TypeScript
function MinLength(min: number) {
return function(target: any, key: string) {
let value: string;
Object.defineProperty(target, key, {
get: () => value,
set: (newVal: string) => {
if (newVal.length < min)
throw new Error(`${key} must be at least ${min} chars`);
value = newVal;
}
});
};
}
class User {
@MinLength(3)
name: string = "";
}
TS follows the ESM standard. Any file with a top-level import or export is a module.
TypeScript
// math.ts
export function add(a: number, b: number): number {
return a + b;
}
// app.ts
import { add } from "./math"; // named import
import * as math from "./math"; // namespace import
Namespaces predate ES modules. They're still used in .d.ts files and ambient declarations but should be avoided in application code.
TypeScript
namespace Validation {
export interface Validator {
isValid(s: string): boolean;
}
export class EmailValidator implements Validator {
isValid(s: string) { return s.includes("@"); }
}
}
const v = new Validation.EmailValidator();
.d.ts)Declaration files describe the shape of JS code so TS can type-check it. They contain only types, no runtime code.
TypeScript
// global.d.ts -- add types for things TS doesn't know about
declare module "*.css" {
const classes: { [key: string]: string };
export default classes;
}
declare module "*.svg" {
const content: string;
export default content;
}
.css file exports. You need a declaration file (like above) or the *.css module declaration in a .d.ts file that your tsconfig includes. Without it, TS says "Cannot find module './styles.css'." Same applies to .svg, .png, .json (unless resolveJsonModule is on), etc.
TypeScript
// Extend Express Request type to add your custom user field
declare module "express" {
interface Request {
user?: { id: string; role: string };
}
}
// Now req.user is typed everywhere in your Express app
TypeScript
/// <reference types="node" />
/// <reference path="./globals.d.ts" />
// These are legacy -- prefer tsconfig "types" and "include" instead
Every TS project has a tsconfig.json. Here are the options that actually matter, grouped by what they do.
JSON
{
"compilerOptions": {
// What module system to output
"module": "NodeNext", // or "ESNext", "CommonJS"
// How TS finds modules when you import
"moduleResolution": "NodeNext", // or "Bundler" for Vite/Webpack
// Where compiled JS goes
"outDir": "./dist",
// Root of source files
"rootDir": "./src",
// Allow importing JSON files
"resolveJsonModule": true,
// Path aliases: import from "@/utils" instead of "../../utils"
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
JSON
{
"compilerOptions": {
"strict": true, // Enables ALL strict checks below:
// "strictNullChecks": true -- null/undefined are their own types
// "strictFunctionTypes": true -- stricter function type checking
// "strictBindCallApply": true -- check bind/call/apply args
// "noImplicitAny": true -- error on implicit 'any'
// "noImplicitThis": true -- error on implicit 'this'
"noUncheckedIndexedAccess": true, // obj[key] includes undefined
"noUnusedLocals": true,
"noUnusedParameters": true
}
}
JSON
{
"compilerOptions": {
// What JS version to compile down to
"target": "ES2022",
// What APIs are available (DOM, ES2022, etc.)
"lib": ["ES2022", "DOM", "DOM.Iterable"],
// Emit .d.ts files for libraries
"declaration": true,
// Generate source maps for debugging
"sourceMap": true,
// Allow JS files in TS project
"allowJs": true,
// Skip type-checking node_modules .d.ts
"skipLibCheck": true
}
}
JSON
{
"compilerOptions": {
// Allow `import React from "react"` even if module has no default export
"esModuleInterop": true,
// Allow `import x from "./file"` where file is CJS
"allowSyntheticDefaultImports": true,
// Ensure consistent casing in imports (catches bugs on case-insensitive OS)
"forceConsistentCasingInFileNames": true,
// Each file is treated as a separate module (faster builds, needed for Babel)
"isolatedModules": true
}
}
npx tsc --init -- generates a tsconfig with all options commented out@tsconfig/node20 -- community base config for Node 20@tsconfig/strictest -- maximum strictness
The TS compiler (tsc) does two separate jobs: type checking and code emission. Modern setups often split these.
Bash
# Type-check only (no output)
tsc --noEmit
# Compile and emit JS
tsc
# Watch mode
tsc --watch
# Check a specific file
tsc --noEmit src/index.ts
Text
Source (.ts) → Scanner → Tokens → Parser → AST → Binder → Symbols
→ Type Checker → Diagnostics (errors)
→ Emitter → Output (.js, .d.ts, .js.map)
This gives you fast builds + full type safety from separate type-check step.
For monorepos, use references to compile packages in order.
JSON
// tsconfig.json (root)
{
"references": [
{ "path": "./packages/shared" },
{ "path": "./packages/api" },
{ "path": "./packages/web" }
]
}
TypeScript
// Assertion (you're telling TS, "trust me") -- risky
const el = document.getElementById("app") as HTMLDivElement;
// Type guard (TS verifies at runtime) -- safe
function isHTMLDiv(el: Element | null): el is HTMLDivElement {
return el instanceof HTMLDivElement;
}
TypeScript
type Action = { type: "add" } | { type: "remove" } | { type: "update" };
function handle(action: Action) {
switch (action.type) {
case "add": break;
case "remove": break;
case "update": break;
default: {
// If you miss a case, this line errors at compile time
const _exhaustive: never = action;
throw new Error(`Unhandled: ${_exhaustive}`);
}
}
}
TypeScript
// Prevent mixing up IDs that are all strings
type UserId = string & { __brand: "UserId" };
type PostId = string & { __brand: "PostId" };
function getUser(id: UserId) { /* ... */ }
const uid = "abc" as UserId;
const pid = "abc" as PostId;
getUser(uid); // ✅
getUser(pid); // ❌ Type 'PostId' is not assignable to 'UserId'
TypeScript
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type ApiRoute = `/${string}`;
type Endpoint = `${HttpMethod} ${ApiRoute}`;
// "GET /users" ✅ "PATCH /users" ❌
// Powerful with mapped types:
type EventMap<T> = {
[K in keyof T as `on${Capitalize<string & K>}`]: (val: T[K]) => void;
};
TypeScript
type Colors = Record<string, [number, number, number] | string>;
// 'satisfies' validates the type but keeps the NARROW inferred type
const palette = {
red: [255, 0, 0],
green: "#00ff00",
} satisfies Colors;
palette.red[0]; // ✅ type: number (not string | number[] -- kept narrow)
palette.green.toUpperCase(); // ✅ type: string
TypeScript
const obj = { a: 1, b: 2 };
Object.keys(obj); // string[], NOT ("a" | "b")[]
// Why? TS structural typing means obj could have MORE keys at runtime.
// Workaround when you're sure:
const keys = Object.keys(obj) as (keyof typeof obj)[];