Today’s goal
Understand what TypeScript can prove, what it cannot know, and how runtime validation turns untrusted input into trusted application data.
We will connect:
- inference and explicit types;
- unions, narrowing, and exhaustive state design;
- generics and reusable contracts;
- strictness, DOM typing, and assertions;
- APIs, URLs, storage, forms, and configuration;
- parsers, schemas, branded values, and trusted domain data.
By the end of today you can
- model domain states instead of decorating arbitrary objects;
- use
unknownat an external boundary; - narrow values with evidence and discriminated unions;
- write generic result and collection APIs;
- keep strict TypeScript useful rather than ceremonial;
- distinguish a type assertion from a runtime conversion;
- validate API, URL, storage, form, and configuration data;
- separate transport types from domain types;
- create branded identifiers only after validation;
- explain why static types do not replace tests or runtime contracts.
Start with inference
const pageSize = 20;
const status = "loading";
const visible = true;
// pageSize: number
// status: string (in a mutable binding)
// visible: boolean
Inference keeps local code readable and lets the implementation remain the source of truth.
Add an annotation when it documents intent, constrains a boundary, or catches an important mistake.
Widening and literal values
const fixedStatus = "loading"; // "loading"
let changingStatus = "loading"; // string
const config = { mode: "dark" }; // { mode: string }
Literal information can widen when a value must remain mutable.
Use literal types deliberately when a small vocabulary is part of the contract.
Type aliases compose precisely
type ProductId = string;
type Currency = "IQD" | "USD";
type ProductSummary = Pick<Product, "id" | "title">;Aliases are especially expressive for unions, tuples, mapped types, conditional types, and domain vocabulary.
Choose the form that communicates the design; both participate in structural typing.
Literal types create a controlled vocabulary
type Theme = "light" | "dark";
type SortDirection = "ascending" | "descending";
function setTheme(theme: Theme) {}
setTheme("dark");
// setTheme("blue"); // compile-time error
Small finite sets should be visible in the type rather than repeated as undocumented strings.
Narrow with property checks
type Failure = { message: string; code: number };
type Success = { data: Product[] };
function describe(value: Failure | Success) {
if ("data" in value) return `${value.data.length} products`;
return `${value.code}: ${value.message}`;
}Property checks are useful, but a stable discriminant is often clearer for important state machines.
Discriminated unions make state explicit
type LoadState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; message: string };Each state carries exactly the data that makes sense in that state.
This prevents impossible combinations such as status: "loading" with stale error and data fields.
Render state by its discriminant
function renderProducts(state: LoadState<Product[]>) {
switch (state.status) {
case "idle": return "Choose a search";
case "loading": return "Loading…";
case "success": return `${state.data.length} results`;
case "error": return state.message;
}
}The branch gives the renderer the exact fields valid for that state.
Intersections combine capabilities
type Identified = { id: string };
type Timestamped = { updatedAt: string };
type StoredProduct = Product & Identified & Timestamped;Use intersections when one value genuinely satisfies multiple independent contracts.
Do not use them to hide a domain model that is becoming difficult to understand.
unknown is the honest boundary type
function receiveExternalValue(value: unknown) {
// value.toString(); // not allowed without proof
if (typeof value === "string") return value.trim();
return "not a string";
}unknown says: a value exists, but this function has not earned knowledge about its shape yet.
It is safer than any because every operation requires evidence.
A type guard is a proof function
function isProduct(value: unknown): value is Product {
if (typeof value !== "object" || value === null) return false;
const record = value as Record<string, unknown>;
return typeof record.id === "string"
&& typeof record.title === "string"
&& typeof record.priceCents === "number";
}The return type tells TypeScript what follows when the function returns true.
The implementation must justify that claim at runtime.
never protects exhaustive decisions
function assertNever(value: never): never {
throw new Error(`Unhandled state: ${String(value)}`);
}
function label(state: LoadState<unknown>) {
switch (state.status) {
case "idle": return "Idle";
case "loading": return "Loading";
case "success": return "Ready";
case "error": return "Failed";
default: return assertNever(state);
}
}Adding a new state now creates a compile-time reminder at every incomplete decision.
Generics preserve relationships
function first<T>(items: T[]): T | undefined {
return items[0];
}
const firstProduct = first(products); // Product | undefined
const firstNumber = first([1, 2, 3]); // number | undefined
Generics are not “any with extra syntax”. They carry a relationship between inputs and outputs.
A generic result keeps success data precise
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: string };
function show(result: ApiResult<Product[]>) {
if (!result.ok) return result.error;
return result.data.map(product => product.title);
}The common result contract is reusable while T preserves the domain-specific payload.
Constrain a generic when it needs a capability
function getById<T extends { id: string }>(items: T[], id: string) {
return items.find(item => item.id === id);
}The constraint does not say every T is exactly an object with only id.
It says the function may safely rely on id while preserving additional fields.
Type the element and the event
const input = document.querySelector<HTMLInputElement>("#query");
input?.addEventListener("input", (event) => {
const target = event.currentTarget as HTMLInputElement;
console.log(target.value);
});Use the most specific safe DOM type available, and remember that the element can still be absent.
Assertions, casts, and conversions are different
const value = input as number; // assertion: no runtime work
const number = Number(input); // conversion: runtime work
const parsed = JSON.parse(input); // parsing: produces a runtime value
An assertion changes static knowledge.
A conversion or parser changes or inspects a runtime value.
URL values are strings, even when they look numeric
const rawPage = new URLSearchParams(location.search).get("page");
const page = rawPage === null ? 1 : Number(rawPage);
if (!Number.isInteger(page) || page < 1) {
throw new Error("Invalid page");
}Parsing and domain checks are separate decisions. Number("") becoming 0 may be a valid JavaScript conversion but an invalid page.
Forms produce strings and absence
const formData = new FormData(form);
const rawEmail: unknown = formData.get("email");
if (typeof rawEmail !== "string" || !rawEmail.includes("@")) {
return showError("Enter a valid email");
}The HTML control type is not enough to guarantee the value your domain logic expects.
Manual validation is a parser
function parseProduct(value: unknown): Product {
if (typeof value !== "object" || value === null) {
throw new Error("Product must be an object");
}
const record = value as Record<string, unknown>;
if (typeof record.id !== "string") throw new Error("Product id is invalid");
if (typeof record.title !== "string") throw new Error("Product title is invalid");
if (typeof record.priceCents !== "number") throw new Error("Product price is invalid");
return { id: record.id, title: record.title, priceCents: record.priceCents };
}The returned Product is earned by checks and reconstruction, not by a blind assertion.
Schema libraries make repetitive checks composable
const ProductSchema = z.object({
id: z.string(),
title: z.string(),
priceCents: z.number().int().nonnegative(),
});A schema can centralize parsing, reuse nested rules, report paths, and derive static types.
The library does not remove the need to design the domain contract.
Safe parsing returns a controlled result
const result = ProductSchema.safeParse(payload);
if (!result.success) {
return { ok: false, error: result.error.issues };
}
return { ok: true, data: result.data };This makes invalid input an explicit branch instead of an unexpected exception deep in rendering code.
A complete API boundary
async function loadProducts(): Promise<ApiResult<Product[]>> {
let response: Response;
try {
response = await fetch("/api/products");
} catch {
return { ok: false, error: "Network request failed" };
}
if (!response.ok) return { ok: false, error: `HTTP ${response.status}` };
const payload: unknown = await response.json();
return parseProducts(payload);
}Transport failure and schema failure are separate facts and should remain distinguishable.
The parse-then-trust principle
function useProducts(products: Product[]) {
// No API-shape checks here.
return products.map(product => product.title);
}Trust should be established once at the boundary, then preserved by types and module boundaries.
Repeated checks in every component usually signal that the boundary is in the wrong place.
satisfies checks without widening useful literals
const routes = {
home: "/",
playground: "/playground",
} satisfies Record<string, `/${string}`>;The object is checked against the contract while retaining precise keys and values for later inference.
This is often better than annotating the whole object as Record<string, string>.
When assertions are legitimate
An assertion can be reasonable when:
- a platform API is typed too broadly;
- a checked invariant is understood by the compiler but not expressible locally;
- a test fixture deliberately models a controlled case;
- the assertion is close to the proof and documented.
It is risky when it is used to skip parsing, null checks, or domain decisions.
Structural typing is useful and subtle
type User = { id: string };
type Product = { id: string };
const product: Product = { id: "p-1" };
const user: User = product;Both shapes satisfy the same structure, even if the domain meanings differ.
Shape compatibility does not automatically communicate semantic identity.
Branded types protect semantic identifiers
type ProductId = string & { readonly __brand: "ProductId" };
type UserId = string & { readonly __brand: "UserId" };
function loadProduct(id: ProductId) {}The compiler can now distinguish two strings that represent different kinds of identifier.
The brand has no runtime representation.
Create a brand only after validation
function parseProductId(value: unknown): ProductId {
if (typeof value !== "string" || !/^p-[a-z0-9-]+$/.test(value)) {
throw new Error("Invalid product id");
}
return value as ProductId;
}The assertion is local and justified by the parser’s runtime rule.
Never export a public “brand anything” helper that bypasses the boundary.
Brands are useful when the cost is justified
Consider them for:
- identifiers that are frequently confused;
- validated URLs or paths;
- normalized currency codes;
- security-sensitive tokens with distinct lifecycles.
Do not brand every primitive. Extra vocabulary should reduce real mistakes, not create ceremony.
Typed errors preserve failure meaning
type BoundaryError =
| { kind: "network"; message: string }
| { kind: "http"; status: number }
| { kind: "schema"; issues: string[] };
type Result<T> =
| { ok: true; data: T }
| { ok: false; error: BoundaryError };Callers can render, retry, report, or ignore failures based on their actual cause.
A malformed response should fail clearly
const response = {
products: [
{ id: "p-1", title: "Valid", priceCents: 1000 },
{ id: "p-2", title: 42, priceCents: "free" },
],
};Do not render the first item and silently accept the second.
Decide whether the contract is all-or-nothing, item-level recovery, or partial data with an explicit warning.
Validate, then construct the trusted model
function parseProducts(value: unknown): ApiResult<Product[]> {
if (!Array.isArray(value)) {
return { ok: false, error: "products must be an array" };
}
const products = value.map(parseProduct);
return { ok: true, data: products };
}In production, make the item failure shape explicit and avoid allowing an exception to erase useful context.
The trusted core should stay boring
function total(products: Product[]) {
return products.reduce((sum, product) => sum + product.priceCents, 0);
}Once data is trusted, domain functions should focus on domain behavior.
If every function still checks whether priceCents is a number, the boundary has not done enough work.
Common misconceptions
| Misconception | Better mental model |
|---|---|
as Product validates JSON | It only changes static interpretation |
unknown is inconvenient | It marks work the boundary must do |
any is faster | It removes useful feedback |
| shared types guarantee the API | They describe an agreement, not delivery |
| schemas make domain design unnecessary | They validate a contract you still choose |
| strictness means more code everywhere | It makes important uncertainty visible |
Practical lab: Build a Safe Data Boundary
Create a small API boundary that parses unknown, validates the response, and exposes trusted product data to application code.
The practical moves from static modeling to malformed payloads, manual parsing, schema validation, URL and storage boundaries, and branded IDs.
Troubleshooting guide
| Symptom | Likely cause |
|---|---|
Everything became any | An untyped boundary leaked inward |
| Assertions appear everywhere | Parsing is missing or too far away |
| Components repeat shape checks | Trusted data is not established once |
| Union branches feel awkward | Add a discriminant or redesign the state |
| A guard compiles but fails in production | The predicate claims more than it checks |
| Old storage breaks after a release | Add versions and migration/validation rules |
Completion checklist
- local values rely on inference where appropriate;
- domain states use explicit, meaningful unions;
- external values enter as
unknown; - parsers return useful failure information;
- transport and domain models are separated where needed;
- branded values are created only after validation;
- tests cover malformed and stale inputs;
- trusted core functions do not repeat boundary checks.