Stop Shipping any: Three TypeScript Techniques That Catch What Strict Mode Misses
noUncheckedIndexedAccess, branded types, and satisfies — what each one closes off, and why avoiding any alone isn’t enough.
strict: true is usually treated as the finish line for type safety in a TypeScript codebase. It isn’t. Strict mode rules out one category of mistake — the unchecked null, the implicit any, the loose function parameter — and leaves a second category entirely untouched: values that have the right type and the wrong meaning.
Three additions close most of that gap. One is a compiler flag; the other two are patterns adopted by convention rather than a switch you flip. Only one of the three is technically a “flag” — the term is used loosely for all three below, because the effect is the same in each case: a class of value the compiler used to wave through now has to prove itself.
noUncheckedIndexedAccess
By default, TypeScript lies about arrays. Index into a string[] and you get a string, whether or not that slot exists. The flag makes every indexed read return T | undefined, which is what the runtime was always going to hand you.
// A short row leaves cells[2] undefined at runtime. // Without the flag, TS types it as string and moves on. const cells = line.split(","); return { sku: cells[0].trim(), qty: Number(cells[1]), note: cells[2].trim(), // crashes if the column is missing };
Nothing here is exotic — a row with a missing trailing column is an ordinary parsing edge case, and it is exactly the kind of input a CSV importer eventually receives. Enabling the flag surfaces every indexed read like this one across a codebase in a single pass, which is usually the first time anyone has looked at them together.
const [sku, qty, note] = line.split(",");
if (sku === undefined || qty === undefined) {
return { ok: false, reason: "short row" } as const;
}
return { ok: true, sku: sku.trim(), qty: Number(qty),
note: note?.trim() ?? "" };The honest cost: most of the indexing sites the flag flags are loops and lookups where the index is provably in range, and the fix there is noise — a non-null assertion or a redundant guard. It is worth it anyway. Each of those assertions becomes a written claim someone can dispute, rather than an assumption the compiler was making silently on everyone’s behalf.
Branded types
Two identifiers of the same primitive type are freely interchangeable to the compiler, even when swapping them is a bug. A user id, an order id, and a customer id are all just string, so passing one where another is expected compiles cleanly. Branding attaches a phantom property to the type so they stop being assignable to each other, at zero runtime cost.
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
export type UserId = Brand<string, "UserId">;
export type OrderId = Brand<string, "OrderId">;
// The only way in — one checked entry point per brand.
export const asUserId = (s: string): UserId => {
if (!/^usr_[0-9a-z]{16}$/.test(s)) throw new Error(s);
return s as UserId;
};The failure mode branding prevents doesn’t announce itself. A function that takes (orderId, userId) called with the arguments swapped won’t throw — both are strings, a lookup against the wrong id simply returns no rows, and whatever handled the miss logs it and moves on. Nothing crashes, so nothing gets reported. Branding turns that swap into a compile error at the call site, because UserId and OrderId are no longer the same type just because they share a runtime representation.
Not everything needs it. Branding a type only ever read from one place buys nothing and costs a constructor — the pattern earns its keep on identifiers that move between modules, not on every string field in the schema.
satisfies
A type annotation checks a value and then widens it to the annotation. satisfies checks the value against the same constraint and keeps what it actually is. For any configuration object, that difference is the whole point.
// Annotated: keys widen to string. A typo elsewhere // in the app compiles fine and 404s at runtime. const routes: Record<string, Route> = { … }; // satisfies: still checked, but keys stay literal. const routes = { invoices: { path: "/invoices", auth: true }, status: { path: "/status", auth: false }, } satisfies Record<string, Route>; type RouteName = keyof typeof routes; // "invoices" | "status"
The failure mode satisfies closes: a config object annotated as Record<string, boolean> lets a key rename pass silently everywhere else in the app. A call site that still reads the old key gets undefined, which is falsy — so a feature flag just reads as off, with no error and no log line. Switching the object to satisfies turns every stale key into a compile error immediately, and makes the literal key union available to everything downstream.
What each one is for
If you adopt one, adopt satisfies — it is a keyword, it costs nothing, and it has the best ratio of bugs prevented to lines touched. The indexed-access flag is the one that needs a decision, because it makes the first afternoon after enabling it unpleasant and the payoff arrives later, in an error you never see.
All three close the same kind of gap, in different places: a value the compiler was willing to accept because nobody had described it precisely enough to reject. That is the work that matters. Not eliminating any, which is easy and mostly cosmetic, but narrowing the types you already have until the wrong value has nowhere left to hide.
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}