526 lines
16 KiB
Plaintext
526 lines
16 KiB
Plaintext
---
|
|
title: "Mastering TypeScript: From Basics to Advanced Patterns"
|
|
date: "2025-01-15"
|
|
excerpt: "A comprehensive guide to TypeScript's type system, from basic types to advanced patterns like conditional types, mapped types, and generics."
|
|
author: Alex Chen
|
|
tags:
|
|
- typescript
|
|
- programming
|
|
- tutorial
|
|
coverImage: /images/typescript-cover.jpg
|
|
---
|
|
|
|
Welcome to this comprehensive TypeScript tutorial! Whether you are migrating a JavaScript project or starting fresh, this guide will walk you through TypeScript's type system from fundamentals to advanced patterns used in production codebases.
|
|
|
|
## 1. Understanding the Type System
|
|
|
|
TypeScript's type system is a layer of **static analysis** that sits atop JavaScript. The compiler (`tsc`) performs type checking at build time and emits plain JavaScript that runs in any engine.
|
|
|
|
```ts
|
|
// Primitive types — the building blocks
|
|
let username: string = "alice";
|
|
let age: number = 28;
|
|
let isActive: boolean = true;
|
|
let tags: string[] = ["typescript", "frontend"];
|
|
let nullable: string | null = null;
|
|
|
|
// Type inference — TypeScript can often guess types for you
|
|
const port = 3000; // inferred as `number`
|
|
const config = { host: "localhost", port: 8080 }; // inferred as `{ host: string; port: number }`
|
|
|
|
// The `unknown` vs `any` distinction — critical for type safety
|
|
let value: unknown = 42;
|
|
// value.toPrecision(3); // ❌ Error: Property 'toPrecision' does not exist on 'unknown'
|
|
|
|
if (typeof value === "number") {
|
|
console.log(value.toPrecision(3)); // ✅ Safe after narrowing
|
|
}
|
|
```
|
|
|
|
> **Key principle:** Prefer `unknown` over `any`. `any` disables all type checking, while `unknown` forces you to perform a runtime check before using the value.
|
|
|
|
## 2. Interfaces, Types, and Object Shapes
|
|
|
|
TypeScript offers two primary ways to define object structures: `interface` and `type`. Understanding when to use each is essential for clean codebases.
|
|
|
|
```ts
|
|
// --- Interface: best for object contracts (open extension via declaration merging) ---
|
|
interface BaseConfig {
|
|
debug: boolean;
|
|
logLevel: "info" | "warn" | "error";
|
|
}
|
|
|
|
// Declaration merging: multiple declarations are combined
|
|
interface BaseConfig {
|
|
retryCount: number;
|
|
}
|
|
|
|
// --- Type alias: better for unions, intersections, and mapped types ---
|
|
type Status = "idle" | "loading" | "success" | "error";
|
|
|
|
// Intersection types — composing types
|
|
type AdminConfig = BaseConfig & {
|
|
permissions: string[];
|
|
};
|
|
|
|
// --- Practical example: a fully-typed service layer ---
|
|
interface DatabaseClient {
|
|
connect(): Promise<void>;
|
|
disconnect(): Promise<void>;
|
|
query<T>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
}
|
|
|
|
type GetUserById = (id: number) => Promise<{ id: number; name: string } | null>;
|
|
|
|
async function createUserClient(dsn: string): Promise<DatabaseClient> {
|
|
const connection = await fetch(`https://${dsn}/connect`);
|
|
if (!connection.ok) throw new Error(`Connection failed: ${connection.status}`);
|
|
|
|
return {
|
|
async connect() {
|
|
await connection.json();
|
|
},
|
|
async disconnect() {
|
|
await connection.json();
|
|
},
|
|
async query<T>(sql: string, params: unknown[] = []) {
|
|
const response = await connection.clone().json();
|
|
return response.rows as T[];
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
## 3. Generics — Writing Reusable, Type-Safe Code
|
|
|
|
Generics let you write functions and classes that work with multiple types while preserving type information. They are the backbone of reusable TypeScript libraries.
|
|
|
|
```ts
|
|
// --- Generic function: identity with preserved type ---
|
|
function identity<T>(value: T): T {
|
|
return value;
|
|
}
|
|
|
|
const num = identity(42); // num has type `number`
|
|
const str = identity("hi"); // str has type `string`
|
|
|
|
// --- Constrained generics: limiting what types are accepted ---
|
|
interface HasId {
|
|
id: number;
|
|
}
|
|
|
|
function findFirst<T extends HasId>(items: T[], predicate: (item: T) => boolean): T | undefined {
|
|
return items.find(predicate);
|
|
}
|
|
|
|
// --- Generic type with default — useful for API client patterns ---
|
|
type ApiResponse<TData, TError = string> =
|
|
| { status: "success"; data: TData }
|
|
| { status: "error"; error: TError };
|
|
|
|
type UserApiResponse = ApiResponse<{ id: number; name: string }>;
|
|
|
|
// --- Real-world: a typed event emitter ---
|
|
type EventMap = {
|
|
click: { x: number; y: number };
|
|
keydown: { key: string; code: string };
|
|
load: { url: string };
|
|
};
|
|
|
|
class TypedEmitter<Map extends Record<string, unknown>> {
|
|
private handlers = new Map<string, Array<(payload: unknown) => void>>();
|
|
|
|
on<K extends keyof Map>(event: K, handler: (payload: Map[K]) => void): void {
|
|
const key = event as string;
|
|
if (!this.handlers.has(key)) {
|
|
this.handlers.set(key, []);
|
|
}
|
|
this.handlers.get(key)!.push(handler as (payload: unknown) => void);
|
|
}
|
|
|
|
emit<K extends keyof Map>(event: K, payload: Map[K]): void {
|
|
this.handlers.get(event as string)?.forEach((h) => h(payload));
|
|
}
|
|
}
|
|
|
|
// Usage — type safety for every event
|
|
const emitter = new TypedEmitter<EventMap>();
|
|
|
|
emitter.on("click", (payload) => {
|
|
// payload is { x: number; y: number } — auto-completion works!
|
|
console.log(`Clicked at ${payload.x}, ${payload.y}`);
|
|
});
|
|
|
|
emitter.on("keydown", (payload) => {
|
|
// payload is { key: string; code: string }
|
|
if (payload.key === "Escape") {
|
|
console.log("User pressed Escape");
|
|
}
|
|
});
|
|
```
|
|
|
|
## 4. Utility Types — Building Blocks for Complex Schemas
|
|
|
|
TypeScript ships with a rich set of built-in utility types that transform existing types. Mastering these is essential for writing maintainable type definitions.
|
|
|
|
```ts
|
|
interface Article {
|
|
id: number;
|
|
title: string;
|
|
body: string;
|
|
tags: string[];
|
|
publishedAt: Date;
|
|
authorId: number;
|
|
}
|
|
|
|
// Partial<T> — make all properties optional
|
|
type ArticleInput = Partial<Article>;
|
|
|
|
const draft: ArticleInput = { title: "Hello World" }; // id, body, etc. are all optional
|
|
|
|
// Required<T> — make all properties required
|
|
function upsertArticle(input: Required<Article>): void {
|
|
console.log(`Upserting article: ${input.title}`);
|
|
}
|
|
|
|
// Pick<T, K> — select a subset of properties
|
|
type ArticleSummary = Pick<Article, "id" | "title" | "publishedAt">;
|
|
|
|
const summary: ArticleSummary = {
|
|
id: 1,
|
|
title: "TypeScript Basics",
|
|
publishedAt: new Date(),
|
|
};
|
|
|
|
// Omit<T, K> — exclude certain properties
|
|
type ArticlePreview = Omit<Article, "body">;
|
|
// Useful: body is too large to send in a list response
|
|
|
|
// Record<K, T> — map-like type
|
|
const statusMessages: Record<Status, string> = {
|
|
idle: "No request in progress",
|
|
loading: "Fetching data...",
|
|
success: "Data loaded",
|
|
error: "Request failed",
|
|
};
|
|
|
|
// Readonly<T> — make all properties immutable
|
|
interface Config {
|
|
host: string;
|
|
port: number;
|
|
}
|
|
|
|
const config: Readonly<Config> = { host: "localhost", port: 3000 };
|
|
// config.port = 8080; // ❌ Error: Cannot assign to 'port' because it is a read-only property.
|
|
|
|
// --- Advanced: extracting types from values ---
|
|
const API_ENDPOINTS = {
|
|
users: "/api/users",
|
|
articles: "/api/articles",
|
|
comments: "/api/comments",
|
|
} as const;
|
|
|
|
// Type is `"users" | "articles" | "comments"`
|
|
type Endpoint = keyof typeof API_ENDPOINTS;
|
|
|
|
// Type is `"/api/users" | "/api/articles" | "/api/comments"`
|
|
type EndpointUrl = (typeof API_ENDPOINTS)[keyof typeof API_ENDPOINTS];
|
|
|
|
// --- Extract and Exclude type utilities ---
|
|
type NumericFields = Extract<keyof Article, "id" | "authorId" | "publishedAt">;
|
|
// NumericFields is "id" | "authorId" — only string keys that are numbers? No, extracted by assignment.
|
|
|
|
// Using Exclude to remove unwanted keys
|
|
type NonNullableFields = Exclude<keyof Article, "body">;
|
|
```
|
|
|
|
## 5. Type Guards and Runtime Narrowing
|
|
|
|
TypeScript narrows types at compile time based on runtime checks. This bridges the gap between static types and dynamic runtime behavior.
|
|
|
|
```ts
|
|
// --- typeof type guards ---
|
|
function formatValue(value: string | number): string {
|
|
if (typeof value === "number") {
|
|
return value.toFixed(2);
|
|
}
|
|
return value.toUpperCase();
|
|
}
|
|
|
|
// --- in type guard ---
|
|
interface Cat {
|
|
meow(): void;
|
|
furColor: string;
|
|
}
|
|
|
|
interface Dog {
|
|
bark(): void;
|
|
breed: string;
|
|
}
|
|
|
|
function describePet(pet: Cat | Dog): string {
|
|
if ("meow" in pet) {
|
|
// TypeScript knows pet is Cat here
|
|
return `A ${pet.furColor} cat says meow!`;
|
|
}
|
|
// Here pet is Dog
|
|
return `A ${pet.breed} dog says woof!`;
|
|
}
|
|
|
|
// --- Custom type guard — reusable predicate ---
|
|
function isUser(obj: unknown): obj is { id: number; name: string; email: string } {
|
|
return (
|
|
typeof obj === "object" &&
|
|
obj !== null &&
|
|
"id" in obj &&
|
|
"name" in obj &&
|
|
"email" in obj &&
|
|
typeof (obj as any).id === "number" &&
|
|
typeof (obj as any).name === "string" &&
|
|
typeof (obj as any).email === "string"
|
|
);
|
|
}
|
|
|
|
// --- Discriminated unions — the most powerful narrowing pattern ---
|
|
type NetworkResult<T> =
|
|
| { status: "pending"; timestamp: number }
|
|
| { status: "success"; data: T; timestamp: number }
|
|
| { status: "error"; error: { code: number; message: string }; timestamp: number };
|
|
|
|
function renderResult<T>(result: NetworkResult<T>): string {
|
|
switch (result.status) {
|
|
case "pending":
|
|
return `Loading since ${result.timestamp}`;
|
|
case "success":
|
|
return `Got ${result.data as unknown as string}`;
|
|
case "error":
|
|
return `Error ${result.error.code}: ${result.error.message}`;
|
|
}
|
|
}
|
|
|
|
// Exhaustiveness checking — catch unhandled cases at compile time
|
|
function assertNever(value: never): never {
|
|
throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
|
|
}
|
|
|
|
function exhaustiveCheck(result: NetworkResult<T>): never {
|
|
return assertNever(result); // ❌ Compile error if a case is missing!
|
|
}
|
|
```
|
|
|
|
## 6. Advanced Patterns: Decorators and Conditional Types
|
|
|
|
These advanced patterns appear frequently in enterprise TypeScript codebases. Let's explore practical uses for both.
|
|
|
|
```ts
|
|
// --- Decorators (experimental syntax, widely used in NestJS, Angular) ---
|
|
function LogExecution(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
|
|
const originalMethod = descriptor.value;
|
|
|
|
descriptor.value = async function (...args: unknown[]) {
|
|
const start = performance.now();
|
|
console.log(`[LOG] ${propertyKey} called with args:`, args);
|
|
|
|
try {
|
|
const result = await originalMethod.apply(this, args);
|
|
const elapsed = performance.now() - start;
|
|
console.log(`[LOG] ${propertyKey} completed in ${elapsed.toFixed(2)}ms`);
|
|
return result;
|
|
} catch (error) {
|
|
console.error(`[LOG] ${propertyKey} threw:`, error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
return descriptor;
|
|
}
|
|
|
|
class DataService {
|
|
@LogExecution
|
|
async fetchData(url: string): Promise<Record<string, unknown>> {
|
|
const response = await fetch(url);
|
|
return response.json();
|
|
}
|
|
}
|
|
|
|
// --- Conditional types — types that depend on other types ---
|
|
type Flattened<T> = T extends Array<infer U> ? U : T;
|
|
|
|
type A = Flattened<number[]>; // number
|
|
type B = Flattened<string>; // string
|
|
type C = Flattened<boolean[]>; // boolean
|
|
|
|
// --- Conditional with `infer` in multiple positions ---
|
|
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
|
|
|
|
type MaybePromise<T> = T extends Promise<any> | PromiseLike<any>
|
|
? Promise<Awaited<T>>
|
|
: Promise<T>;
|
|
|
|
// --- Brand types for runtime type discrimination ---
|
|
interface UserId {
|
|
_tag: "UserId";
|
|
value: number;
|
|
}
|
|
|
|
interface ArticleId {
|
|
_tag: "ArticleId";
|
|
value: number;
|
|
}
|
|
|
|
// These are NOT interchangeable despite both being `{ value: number }`
|
|
const userId: UserId = { _tag: "UserId", value: 42 };
|
|
// const articleId: ArticleId = userId; // ❌ Error!
|
|
|
|
// Factory functions with branded return types
|
|
function createUserId(id: number): UserId {
|
|
if (id <= 0) throw new Error("Invalid user ID");
|
|
return { _tag: "UserId", value: id };
|
|
}
|
|
|
|
function createArticleId(id: number): ArticleId {
|
|
if (id <= 0) throw new Error("Invalid article ID");
|
|
return { _tag: "ArticleId", value: id };
|
|
}
|
|
```
|
|
|
|
## 7. Error Handling Best Practices
|
|
|
|
A robust error handling strategy improves debugging and makes failures explicit in the type system.
|
|
|
|
```ts
|
|
// --- Result type pattern — explicit error handling without exceptions ---
|
|
type Result<T, E = Error> =
|
|
| { ok: true; value: T }
|
|
| { ok: false; error: E };
|
|
|
|
function safeParseJson(input: string): Result<unknown> {
|
|
try {
|
|
return { ok: true, value: JSON.parse(input) };
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
error: error instanceof Error ? error : new Error(String(error)),
|
|
};
|
|
}
|
|
}
|
|
|
|
function chainResults<T, U>(
|
|
result: Result<T>,
|
|
fn: (value: T) => Result<U>
|
|
): Result<U> {
|
|
if (!result.ok) return { ok: false, error: result.error };
|
|
return fn(result.value);
|
|
}
|
|
|
|
// Usage — no try/catch needed at call sites
|
|
const jsonResult = safeParseJson('{"name":"Alice"}');
|
|
const parsed = chainResults(jsonResult, (value) => ({
|
|
ok: true,
|
|
value: (value as Record<string, unknown>).name as string,
|
|
}));
|
|
|
|
// --- Custom error classes with named constructors ---
|
|
class AppError extends Error {
|
|
constructor(
|
|
public readonly code: string,
|
|
message: string,
|
|
public readonly metadata?: Record<string, unknown>
|
|
) {
|
|
super(message);
|
|
this.name = "AppError";
|
|
}
|
|
}
|
|
|
|
class ValidationError extends AppError {
|
|
constructor(
|
|
field: string,
|
|
message: string,
|
|
metadata?: Record<string, unknown>
|
|
) {
|
|
super("VALIDATION_ERROR", `${field}: ${message}`, { field, ...metadata });
|
|
this.name = "ValidationError";
|
|
}
|
|
}
|
|
|
|
class NotFoundError extends AppError {
|
|
constructor(resource: string, id: string) {
|
|
super("NOT_FOUND", `${resource} with id "${id}" not found`, { resource, id });
|
|
this.name = "NotFoundError";
|
|
}
|
|
}
|
|
|
|
// --- Centralized error handler ---
|
|
function handleError(error: unknown): { status: number; message: string } {
|
|
if (error instanceof AppError) {
|
|
switch (error.code) {
|
|
case "VALIDATION_ERROR":
|
|
return { status: 400, message: error.message };
|
|
case "NOT_FOUND":
|
|
return { status: 404, message: error.message };
|
|
default:
|
|
return { status: 500, message: "An unexpected error occurred" };
|
|
}
|
|
}
|
|
|
|
if (error instanceof Error) {
|
|
return { status: 500, message: error.message };
|
|
}
|
|
|
|
return { status: 500, message: "Unknown error" };
|
|
}
|
|
```
|
|
|
|
## 8. Configuration and Build Setup
|
|
|
|
A proper TypeScript project needs thoughtful configuration. Here is a production-ready `tsconfig.json` pattern:
|
|
|
|
```json
|
|
{
|
|
"compilerOptions": {
|
|
"target": "ES2022",
|
|
"module": "NodeNext",
|
|
"moduleResolution": "NodeNext",
|
|
"lib": ["ES2022"],
|
|
"outDir": "./dist",
|
|
"rootDir": "./src",
|
|
"strict": true,
|
|
"noImplicitAny": true,
|
|
"strictNullChecks": true,
|
|
"strictFunctionTypes": true,
|
|
"noUnusedLocals": true,
|
|
"noUnusedParameters": true,
|
|
"noImplicitReturns": true,
|
|
"esModuleInterop": true,
|
|
"skipLibCheck": true,
|
|
"forceConsistentCasingInFileNames": true,
|
|
"resolveJsonModule": true,
|
|
"declaration": true,
|
|
"sourceMap": true
|
|
},
|
|
"include": ["src/**/*.ts"],
|
|
"exclude": ["node_modules", "dist"]
|
|
}
|
|
```
|
|
|
|
Key flags explained:
|
|
1. `strict: true` — enables all strict type checking options at once
|
|
2. `noImplicitAny: true` — disallows implicit `any` types
|
|
3. `strictNullChecks: true` — `null` and `undefined` are distinct types
|
|
4. `noUnusedLocals` / `noUnusedParameters` — catch dead code at compile time
|
|
5. `declaration: true` — emits `.d.ts` files for library consumers
|
|
|
|
## Next Steps
|
|
|
|
You now have a solid foundation in TypeScript's type system. To continue your journey, here are some recommended directions:
|
|
|
|
- **Deep dive into generics** — explore higher-kinded types and type-level programming
|
|
- **Learn about module resolution** — understand `--moduleResolution` strategies (`NodeNext` vs `Node16` vs `Classic`)
|
|
- **Explore testing with TypeScript** — type-safe assertions with libraries like `vitest` and `tsd`
|
|
- **Study architectural patterns** — dependency injection, CQRS, and hexagonal architecture in TypeScript
|
|
- **Contribute to type definitions** — help maintain `@types/*` packages on DefinitelyTyped
|
|
|
|
TypeScript is a journey, not a destination. The type system rewards patience and pays dividends in developer confidence and reduced bug rates. Start small, enforce `strict` mode early, and let the compiler be your guide.
|
|
|
|
Happy coding! 🚀
|