My aim is not to memorise clever type expressions. It is to use TypeScript to describe real program states, catch incorrect assumptions early and make application boundaries honest.
1. TypeScript is a model of JavaScript code
TypeScript is a statically analysed superset of JavaScript. It checks source code before execution and normally erases its type syntax when producing JavaScript.
const title: string = "Closures";
const minutes: number = 25;
The browser receives JavaScript, not a runtime string guard. This distinction is the foundation:
type Lesson = { id: string; title: string };
async function loadLesson(): Promise<Lesson> {
const response = await fetch("/api/lesson");
return response.json() as Promise<Lesson>;
}
The assertion tells the compiler to trust us. It does not validate the response. Network data should enter as unknown and be checked with a runtime schema.
const LessonSchema = z.object({
id: z.string(),
title: z.string().min(1),
});
const raw: unknown = await response.json();
const lesson = LessonSchema.parse(raw);
Definition: static types describe what values are permitted during analysis; runtime validation examines values that actually arrive while the program runs.
2. Inference, annotations and literal types
TypeScript infers types from initial values and usage:
const course = "JavaScript"; // literal type "JavaScript"
let topic = "scope"; // widened to string because it can change
Annotate public contracts, domain boundaries and places where inference cannot express intent. Avoid annotating every obvious local variable.
type Status = "draft" | "published" | "archived";
function publish(status: Status): Status {
return status === "draft" ? "published" : status;
}
Literal unions make illegal strings a compile-time error. as const preserves literal values and readonly properties:
const roles = ["viewer", "editor", "owner"] as const;
type Role = typeof roles[number];
3. Object types, optional values and readonly contracts
interface Learner {
readonly id: string;
displayName: string;
avatarUrl?: string;
}
An optional property may be absent. With exactOptionalPropertyTypes, absence is not automatically identical to explicitly assigning undefined.
function avatar(learner: Learner): string {
return learner.avatarUrl ?? "/images/default-avatar.svg";
}
readonly protects assignment through that type; it does not deep-freeze the runtime object.
type Course = Readonly<{
title: string;
lessons: readonly string[];
}>;
Prefer unknown over any. any turns off checking and spreads uncertainty. unknown requires evidence.
function message(error: unknown): string {
return error instanceof Error ? error.message : "Unknown error";
}
4. Narrowing and control-flow analysis
Narrowing refines a broad type after runtime checks.
function format(value: string | number): string {
if (typeof value === "number") return value.toFixed(2);
return value.toUpperCase();
}
TypeScript follows branches, returns, assignments and guards. Useful built-in guards include typeof, instanceof, equality, truthiness, Array.isArray and the in operator.
type ApiError = { error: string };
type ApiSuccess = { data: Lesson };
function handle(result: ApiError | ApiSuccess) {
if ("error" in result) showError(result.error);
else renderLesson(result.data);
}
Custom predicates must be correct; the compiler trusts their claim.
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every(item => typeof item === "string");
}
5. Discriminated unions and impossible states
Avoid bags of unrelated optionals:
type LoadState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; message: string };
function render(state: LoadState<Lesson>) {
switch (state.status) {
case "idle": return null;
case "loading": return "Loading…";
case "success": return state.data.title;
case "error": return state.message;
}
}
never helps check exhaustiveness:
function unreachable(value: never): never {
throw new Error(`Unexpected state: ${JSON.stringify(value)}`);
}
The design goal is not “maximum types.” It is making invalid combinations hard to create.
6. Functions and variance
type SaveLesson = (lesson: Lesson, signal?: AbortSignal) => Promise<void>;
Function compatibility depends on parameter and return relationships. A handler requiring a more specialised argument cannot safely stand in for one that may receive a broader value.
type Animal = { name: string };
type Dog = Animal & { bark(): void };
const handleDog = (dog: Dog) => dog.bark();
// const handleAnimal: (animal: Animal) => void = handleDog; // unsafe
Enable strictFunctionTypes through strict. Understand variance before adding annotations merely to silence errors.
7. Generics express relationships
Generics are useful when types in a contract relate to one another.
function first<T>(items: readonly T[]): T | undefined {
return items[0];
}
const name = first(["scope", "closure"]); // string | undefined
The important result is that the input element type and output type remain connected.
Constraints describe required capabilities:
function indexById<T extends { id: PropertyKey }>(items: readonly T[]): Map<T["id"], T> {
return new Map(items.map(item => [item.id, item]));
}
Do not write a generic that accepts only one concrete type or whose type parameter appears once. Generics should preserve useful information.
8. Type operators
type LessonKeys = keyof Lesson;
type LessonId = Lesson["id"];
type Loader = typeof loadLesson;
type Loaded = Awaited<ReturnType<Loader>>;
Mapped types transform properties:
type FormErrors<T> = {
[Key in keyof T]?: string;
};
Conditional types choose based on assignability:
type ElementOf<T> = T extends readonly (infer Item)[] ? Item : T;
Template-literal types build constrained strings:
type HttpMethod = "get" | "post" | "delete";
type HandlerName = `on${Capitalize<HttpMethod>}`;
Use these tools to model a real API, not to create puzzles no teammate can maintain.
9. Structural typing and excess-property checks
TypeScript compares shapes rather than requiring nominal declarations.
type Named = { name: string };
const user = { name: "Faz", role: "owner" };
const named: Named = user;
Fresh object literals receive excess-property checking:
// const wrong: Named = { name: "Faz", role: "owner" };
This is a helpful typo detector, not a promise that objects contain only declared properties at runtime. Use branded types when structurally identical primitives must not be mixed:
type UserId = string & { readonly __brand: "UserId" };
type LessonId = string & { readonly __brand: "LessonId" };
10. Modules, configuration and application boundaries
Use import type when importing only type information. Configure module resolution to match the actual bundler or runtime.
import type { Lesson } from "./lesson.js";
import { LessonSchema } from "./lesson.js";
Strong baseline options include strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, useUnknownInCatchVariables and noImplicitOverride.
Keep separate configurations when browser, server, worker and test projects have different globals and module behavior.
11. React and API examples
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "danger";
loading?: boolean;
};
function Button({ variant = "primary", loading, children, ...props }: ButtonProps) {
return <button {...props} disabled={props.disabled || loading}>{children}</button>;
}
const CreateLesson = z.object({
title: z.string().trim().min(1).max(120),
minutes: z.coerce.number().int().nonnegative(),
});
app.post("/lessons", async (req, res) => {
const input = CreateLesson.parse(req.body);
const lesson = await service.create(input);
res.status(201).json({ data: lesson });
});
Types connect layers during development; schemas defend runtime boundaries.
12. Common mistakes and mastery drills
- using
anyto make an error disappear; - asserting
as Tinstead of proving a value isT; - creating one enormous global type;
- making every function generic;
- treating optional properties as a substitute for domain states;
- forgetting that readonly and private TypeScript constructs have specific runtime limits;
- exporting internal database shapes directly as API contracts.
Mastery statement: TypeScript is most valuable when it makes relationships, alternatives and boundaries explicit while respecting that JavaScript still owns runtime behavior.
