Element types do not guarantee a position
A string[] describes element types supplied by the collection, but the array may be empty. noUncheckedIndexedAccess adds undefined to indexed reads without a guaranteed position. State this option explicitly: strict being true does not establish that it is enabled. A numeric or textual index signature also does not guarantee every possible key exists.
When reviewing access, identify the actual guarantee: is the position declared in a tuple, obtained through iteration, or calculated from a request? In the last case, absence can be normal and deserves a branch. A ! assertion removes the possibility from the caller’s type, but creates no missing element.
Retain and narrow the read
With checked indexed access enabled, an i < rows.length loop condition does not automatically remove possible undefined from rows[i]. A useful solution retains the value, checks absence, and only then reads its properties. Narrowing thus applies to the read actually used.
Policy depends on the contract. If every position in the interval must exist, an absent position should produce a domain error. Switching to an operation that ignores missing positions would change that requirement. When only traversing elements of a valid dense collection matters, value iteration can better express the goal. Choose the form for required behavior, not merely to remove the warning.
Tuples retain known positions
A readonly [string, number] tuple declares two required positions with different types. Literal index 1 selects number without checking arbitrary length. A literal index outside the tuple is an error. This is more precise than a string | number array when each position has a different meaning.
Optional positions change that precision. In readonly [number, number, number?], the third read admits undefined and length can be 2 or 3. readonly prevents writes through the tuple but adds no positions. If a function only reads a tuple created with as const, its signature should accept a readonly tuple, avoiding an unnecessary mutability requirement.
Zero and absence are different decisions
In a retry limit, zero can mean an explicit decision not to retry. count || 3 replaces zero because it treats it as falsy. count ?? 3 applies the fallback only for null or undefined, retaining zero. If the model admits only number | undefined, an explicit undefined check also works.
A default does not validate the rest of the domain. NaN, a fractional value or a negative limit may still need rejection. If map values were already validated, reading only needs to handle expected absence; if they arrive through an external boundary, validation must happen before the application trusts number. Do not combine those responsibilities in an assertion.
Protection has a defined scope
config?.retry.limit protects against missing config, but not independently missing retry when config exists. Each optional level needs a decision. Another optional chain can be used followed by handling an absent result, or incomplete configuration can be rejected before use. The shortest form is correct only if it represents the intended policy.
Optional chaining also does not protect operations outside it. In item?.amount / computeScale(), the computeScale call remains a separate operand. With strictNullChecks, potentially dividing undefined is flagged. If the intention is to avoid the call when item is absent, control flow needs an explicit branch; adding ?. to the initial access is insufficient.
readonly, aliases and order-changing operations
A readonly number[] parameter can receive a mutable array because the function promises to use it through a read interface. This does not prevent the owner from changing it through another alias. It also creates no copy. Legitimate owner changes remain visible through a view sharing the same reference.
sort changes the array itself. To produce presentation order without changing shared order, copy the array and sort the copy. With numbers, the outer copy suffices to separate ordering. With objects, elements remain shared and another policy may be needed if fields are also changed. Applying as number[] to a readonly input merely hides that responsibility.
Guided reading of a selection
selectRow starts with already validated plain rows. It first rejects indexes that are not nonnegative safe integers. It then retains rows[index] and distinguishes a missing position. Only the remaining branch constructs a new row with both primitive fields. The function uses no ! and does not promise that every numeric index exists.
Trace results for -1, an index beyond the end, and a row whose retries is zero. Zero is preserved because no truthiness test replaces it. Copying separates result fields from future original-row changes, but does not make the result itself immutable. Additional write or validation requirements should be expressed separately.
// TypeScript 7.0; strict: true; noUncheckedIndexedAccess: true; ES2022 library.
// Validated plain rows, dense array, no extra numeric properties.
type Row = { id: string; retries: number };
type Selection =
| { ok: true; row: Row }
| { ok: false; reason: "invalid-index" | "missing-row" };
function selectRow(rows: readonly Row[], index: number): Selection {
if (!Number.isSafeInteger(index) || index < 0) {
return { ok: false, reason: "invalid-index" };
}
const row = rows[index];
if (row === undefined) {
return { ok: false, reason: "missing-row" };
}
return { ok: true, row: { id: row.id, retries: row.retries } };
}Read three cases: a negative index, an index beyond the end and a present index. The read is retained before narrowing. The accepted result copies both primitive fields, so changing the original row later does not change that copy.
Common pitfalls
Using ! at every index; expecting readonly to freeze other aliases; applying || to valid zero; forgetting that ?. protects only its stated chain.
Related topics: Unions, narrowing and state models · Objects and structural compatibility · External data and error boundaries
A typed collection still has bounds, absence and identity. Check the read actually used and explicitly decide whether the result shares or copies data.
Reference: TypeScript: unchecked · TypeScript 7.0; explicit strict and host configuration