- Book: TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You've seen this PR. Someone added a feature flag named
enable_new_checkout. The config loader returns
Record<string, boolean>. Six months later a junior reads the
flag with flags.enable_new_chekout — typo: chekout, missing
the c.
The compiler is fine with it. The runtime returns undefined,
which is falsy, so the new checkout never enables in production.
The metric stays flat for two weeks because nobody expected it
to move much yet. The launch postmortem reads like a bad joke:
a one-letter typo hidden behind an index signature that allowed
any key to look valid.
There is a tsconfig flag for this. It has been in TypeScript
since 4.2. Most codebases never turn it on. Its name is
noPropertyAccessFromIndexSignature, and what it does is small,
specific, and worth more than its character count.
What the flag actually does
By default, when a type has an index signature, TypeScript treats
dot access and bracket access as equivalent. Both compile. Both
return the indexed type. The compiler shrugs at the typo.
type Flags = Record<string, boolean>;
const flags: Flags = loadFlags();
const a = flags.enable_new_checkout; // boolean | undefined
const b = flags["enable_new_checkout"]; // boolean | undefined
Turn the flag on:
// tsconfig.json
{
"compilerOptions": {
"noPropertyAccessFromIndexSignature": true,
"noUncheckedIndexedAccess": true
}
}
Now the dot access is a compile error.
const a = flags.enable_new_checkout;
// ~~~~~~~~~~~~~~~~~~~~
// Property 'enable_new_checkout' comes from an index signature,
// so it must be accessed with ['enable_new_checkout'].
const b = flags["enable_new_checkout"]; // ok
The compiler is drawing a line between two cases that look the
same syntactically. Reading a known property is one case: the
property is declared on the type, so the compiler can verify it
exists. Indexing into a record by an arbitrary key is the other
case, and the compiler cannot verify that key — the access might
miss. Default TypeScript collapses both. The flag separates them.
Known properties of an interface keep dot access.
Reads from Record<string, T> or anything with an index
signature use brackets. The bracket is a visual flag at the call
site that says this can miss.
Pair it with noUncheckedIndexedAccess and the type of the result
becomes T | undefined, which forces a narrowing step before you
use the value. Together the two flags shut down the entire family
of "typo on a record key" bugs.
The bug class it actually kills
The Flags type above is the obvious case. The same shape comes up
everywhere a typed wrapper meets dynamic input: HTTP request
bodies typed as Record<string, unknown> after validation,
translation dictionaries, environment-variable wrappers, form
state, cache stores, DTOs from a generated client where unknown
fields fall back to an index signature.
Every one of these is a typo trap. The flag turns the trap into a
compile error. The cost is one bracket pair at every read site.
What you do not lose: structural typing on declared properties.
interface User {
id: string;
email: string;
preferences: Record<string, boolean>;
}
const u: User = await loadUser();
const id = u.id; // ok — declared property
const e = u.email; // ok — declared property
const dm = u.preferences.darkMode; // error — from index signature
const dm2 = u.preferences["darkMode"]; // ok
User.id and User.email keep dot access because they are
declared. Only the index-signature side of the type asks for
brackets. The flag is precise.
Where the flag does not pay off
Three cases where turning it on is a tax without a return.
Internal config objects with a closed key set. If your
config is { host: string; port: number } declared as an
interface, the flag never fires — those are not index
signatures. Good. But if you typed your config as
Record<string, unknown> because the schema is not stable yet,
the flag will force every read into bracket form. The fix is
not to skip the flag — the fix is to type the config properly.
A z.infer<typeof ConfigSchema> from Zod or
a hand-written interface gives you both checked dot access and
runtime validation.
Truly dynamic look-ups by computed key. If your code already
reads flags[key] where key is a string variable, the flag
changes nothing. You were already using brackets.
Codebases heavy on generated types that pin index signatures
on everything by default. OpenAPI generators tend to add
[key: string]: unknown to objects whose schema declares
additionalProperties: true. Turning the flag on against an
untuned generated client sprays errors across every consumer.
The fix is to tighten the generator config
(additionalProperties: false on the schema side, or
post-processing that drops the index signature when the declared
properties are exhaustive). Plan the generator pass before
you flip the flag.
Migration plan for an existing codebase
For a mid-sized codebase (call it 50k–150k LOC for concreteness),
the plan that works:
Step 1: turn on noUncheckedIndexedAccess first
This is the higher-value flag of the pair. It changes the type
of every indexed read to include undefined. The errors it
generates point at real bugs: code that reads a key, asserts the
result, and crashes when the key is missing. Land this first.
{
"compilerOptions": {
"noUncheckedIndexedAccess": true
}
}
Expect a flood of errors. Most of them are read-then-trust patterns.
The fix is a narrowing check, an ?? default, or a non-null
assertion at a place where you have already validated the key. Land
those fixes per file, in small PRs. Aim for zero errors before
moving on.
Step 2: measure where the flag will fire
Before you flip noPropertyAccessFromIndexSignature, count the
sites it would touch. A small script using the TypeScript compiler
API gives you a precise number.
import {
createProgram,
forEachChild,
isPropertyAccessExpression,
type Node,
type SourceFile,
type TypeChecker,
} from "typescript";
const program = createProgram(["src/index.ts"], {
strict: true,
noPropertyAccessFromIndexSignature: false,
});
const checker: TypeChecker = program.getTypeChecker();
const findings: { file: string; line: number; text: string }[] = [];
function visit(node: Node, sf: SourceFile): void {
if (isPropertyAccessExpression(node)) {
const objType = checker.getTypeAtLocation(node.expression);
const idx = objType.getStringIndexType();
const decl = objType.getProperty(node.name.text);
if (idx && !decl) {
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
findings.push({
file: sf.fileName,
line: line + 1,
text: node.getText(sf),
});
}
}
forEachChild(node, (c) => visit(c, sf));
}
for (const sf of program.getSourceFiles()) {
if (!sf.isDeclarationFile) visit(sf, sf);
}
console.log(`Sites that would change: ${findings.length}`);
for (const f of findings.slice(0, 20)) {
console.log(`${f.file}:${f.line} ${f.text}`);
}
Run it once. The number you get back is the upper bound on the
codemod work. Expect the count to land in the low hundreds for
a typical mid-sized codebase — order-of-magnitude estimate, not
a measurement — with a long-tail distribution: a handful of
files concentrate the bulk of the flips, and the rest are
scattered one-off reads against config records and DTOs.
Step 3: a codemod sketch
The mechanical change is property access on a value whose type
has a string index signature → bracket access. A
ts-morph script handles it cleanly because
it walks the AST and the type-checker together.
import { Project, SyntaxKind } from "ts-morph";
const project = new Project({ tsConfigFilePath: "tsconfig.json" });
const checker = project.getTypeChecker();
for (const sf of project.getSourceFiles()) {
const accesses = sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression);
for (const access of accesses) {
const objType = checker.getTypeAtLocation(access.getExpression());
const idx = objType.getStringIndexType();
const declared = objType.getProperty(access.getName());
if (idx && !declared) {
const objText = access.getExpression().getText();
const propName = access.getName();
access.replaceWithText(`${objText}[${JSON.stringify(propName)}]`);
}
}
sf.saveSync();
}
The script reads each property access, asks the type checker
whether the receiver has a string index signature and no
declared property of that name, and rewrites those into bracket
form using JSON.stringify to handle quotes and escapes. Run it
on a clean working tree. Read the diff before you commit. Real
projects always have one or two cases the script gets wrong —
usually a custom type that smells like an index signature but
isn't, or a property name with a reserved word the codemod did
not anticipate.
After the codemod, flip the flag in tsconfig.json and run tsc.
Anything left is either a real bug the codemod missed or a place
where the type should be tightened from a record to an interface.
Step 4: tighten what the codemod forced into brackets
The rewrite is the floor, not the ceiling. After the codemod
runs, walk the new bracket sites and ask: should this really
be a record, or did we reach for Record<string, T> because we
did not write an interface? Half the time the answer is write
an interface. The flag drove you to the question.
// before
type Config = Record<string, string | undefined>;
const c: Config = loadConfig();
const port = c["port"]; // string | undefined
// after
interface Config {
port: string;
host: string;
logLevel: "debug" | "info" | "warn" | "error";
}
const c: Config = loadConfig();
const port = c.port; // string
The interface gives you typo protection on every property name
without bracket access, exhaustiveness on union members, and a
single point of truth a reviewer can read.
Cost vs benefit on a mid-sized project
The number that matters is not the count of bracket sites. It
is how many of those sites you would have written obj["key"]
anyway, and how many real bugs the flag prevents during the
period it is on.
A useful way to frame the trade-off on a mid-sized codebase:
- A few hundred sites flip from dot to bracket. A ts-morph codemod handles the large majority. The rest need hand fixes, usually cases where the type hint at the receiver was looser than the actual runtime value — the right fix is tightening the type, not rewriting the read.
- The bugs the flag catches are the kind tests miss. Typos in
feature-flag names. Keys that were never declared in the type
but read consistently throughout the code, so every read
returned
undefinedand the call site quietly defaulted. A test that uses the same typo as production code will not surface either of those. - The visible cost is one bracket pair per indexed read, plus the inevitable grumbling. The grumbling fades once someone catches their own typo on a PR before pushing.
A codebase that already favors interfaces over records will see
fewer flips and fewer bugs caught. A codebase wallpapered in
Record<string, unknown> will see hundreds of flips and a
correspondingly larger payoff.
When to skip it
Skip the flag if you are mid-sprint and cannot afford the
migration window, if your codebase leans on generated clients
you do not own and the generator emits index signatures
everywhere, or if you ship a public library where the consumer's
tsconfig drives the choice rather than yours. In every other
shape of TypeScript codebase the flag pays for itself quickly.
Where to start on Monday
Open tsconfig.json, flip noUncheckedIndexedAccess, and ship
the fixes that flag flushes out before you touch
noPropertyAccessFromIndexSignature at all. Once that is green,
run the audit script against your largest package and see what
the count looks like — that number tells you whether this is a
half-day chore or a real migration. Either way, the records the
codemod forces into brackets are the next question worth asking:
which of those should have been interfaces from the start?
If this was useful
Strict-mode flags work as a system, not isolated switches.
TypeScript Essentials walks the lineup as a coherent set:
strict itself, the noUnchecked* family, the access flags,
and the inference flags that interact with them. The TypeScript
Type System is the deep dive on why the checker treats index
signatures this way, and how to design types that do not need
them.
If you are coming from JVM, Kotlin and Java to TypeScript
makes the bridge into TypeScript's structural model. From PHP
8+, PHP to TypeScript covers the same ground from the other
side. TypeScript in Production picks up at the
tsconfig-and-tooling layer where flags like this one live.
The five-book set:
- TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
- The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
- Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
- PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
- TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471
All five books ship in ebook, paperback, and hardcover.

Top comments (0)