- Published on
TypeScript 5.5+ noUncheckedIndexedAccess 오류 실전해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/프론트 공통으로 TypeScript를 엄격하게 운영하다 보면, 어느 순간 noUncheckedIndexedAccess를 켜는 순간 빌드가 붉게 물드는 경험을 하게 됩니다. 특히 TypeScript 5.5+로 올라오면서(또는 기존 코드에 strict 옵션을 단계적으로 적용하면서) 인덱스 접근이 전부 T | undefined로 변해, “분명 런타임에 값이 있는데 왜 타입이 불안정하다고 하지?”라는 불만이 폭발합니다.
하지만 이 옵션은 실제 런타임에서 자주 발생하는 undefined 접근 버그를 컴파일 타임에 끌어올리는 장치입니다. 문제는 “안전해지긴 했는데, 너무 많은 곳이 깨져서 적용이 어렵다”는 점이죠. 이 글에서는 흔히 깨지는 지점을 패턴별로 나누고, 팀 코드베이스에서 대량 수정 가능한 실전 해결법을 제시합니다.
> 관련해서 TypeScript 5.5에서 타입 추론이 바뀌며 noImplicitAny가 갑자기 터지는 케이스도 많습니다. 함께 점검하려면 TypeScript 5.5 noImplicitAny 폭탄 - inferred type 디버깅도 참고하세요.
noUncheckedIndexedAccess가 바꾸는 것
noUncheckedIndexedAccess는 “인덱스로 접근하는 값은 항상 존재한다고 가정하지 말라”는 규칙을 타입 시스템에 강제합니다.
- 배열:
arr[i]의 타입이T→T | undefined - 객체 인덱스 시그니처:
obj[key]의 타입이V→V | undefined Record<string, V>도 마찬가지로V | undefined
즉, 다음이 기본이 됩니다.
// tsconfig: { "compilerOptions": { "noUncheckedIndexedAccess": true, "strict": true } }
const xs: number[] = [1, 2, 3];
const n = xs[10];
// ^? number | undefined
이게 귀찮은 이유는, 기존 코드가 암묵적으로 “있을 것”을 가정하고 작성된 경우가 많기 때문입니다.
오류가 터지는 대표 패턴 6가지와 해결법
1) 배열 인덱스 접근 후 바로 사용
const users = [{ id: "a" }, { id: "b" }];
const firstId = users[0].id;
// Object is possibly 'undefined'.
해결 A: 구조적으로 안전한 분기(가장 권장)
const first = users[0];
if (!first) throw new Error("users is empty");
const firstId = first.id;
해결 B: optional chaining + 기본값(의도가 "없으면 기본값")일 때
const firstId = users[0]?.id ?? "";
해결 C: non-null assertion ! (최후의 수단)
const firstId = users[0]!.id;
!는 정말로 불변 조건이 보장되는 경우에만 쓰세요. 예: 직전에 length 체크가 있고, 그 체크가 리팩터링으로 깨지지 않게 구조화된 경우.
2) Record/객체 맵에서 키로 조회
type User = { id: string; name: string };
const userById: Record<string, User> = {};
function getName(id: string) {
return userById[id].name;
// Object is possibly 'undefined'.
}
여기서 핵심은 Record는 “모든 string 키가 존재”를 의미하지 않는다는 겁니다. 런타임에서는 대부분 부분 맵(partial map)입니다.
해결 A: 반환 타입에 undefined를 반영(가장 정직)
function getName(id: string): string | undefined {
return userById[id]?.name;
}
해결 B: “없으면 에러” 정책이면 명시적으로 throw
function requireUser(id: string): User {
const u = userById[id];
if (!u) throw new Error(`User not found: ${id}`);
return u;
}
function getName(id: string) {
return requireUser(id).name;
}
이 패턴은 대규모 코드베이스에서 특히 유용합니다. requireXxx를 만들어두면, 이후 인덱스 접근 난사를 한 곳에서 수렴시킬 수 있습니다.
3) Object.keys()/for...in에서 키로 접근
다음 코드는 직관적으로 안전해 보이지만, 타입 시스템 관점에서는 k가 string이라 안전하지 않습니다.
const cfg = { host: "localhost", port: 5432 };
for (const k of Object.keys(cfg)) {
const v = cfg[k];
// Element implicitly has an 'any' type ... 또는 v가 (string|number)|undefined로 흐트러짐
}
해결 A: satisfies + 키 타입 보존
const cfg = {
host: "localhost",
port: 5432,
} satisfies Record<string, string | number>;
const keys = Object.keys(cfg) as (keyof typeof cfg)[];
for (const k of keys) {
const v = cfg[k];
// v: string | number (noUncheckedIndexedAccess에서도 보통 undefined가 덜 섞임)
}
해결 B: 타입 안전한 typedKeys 유틸
function typedKeys<T extends object>(o: T): (keyof T)[] {
return Object.keys(o) as (keyof T)[];
}
const cfg = { host: "localhost", port: 5432 };
for (const k of typedKeys(cfg)) {
const v = cfg[k];
}
이 유틸은 프로젝트 전반에 퍼진 Object.keys 문제를 빠르게 정리할 때 효과가 큽니다.
4) Array.prototype.find + 인덱싱/접근
find는 원래부터 T | undefined를 반환합니다. noUncheckedIndexedAccess와 만나면 “undefined 처리”가 더 강제됩니다.
type Item = { id: string; price: number };
const items: Item[] = [];
const price = items.find(x => x.id === "a").price;
// Object is possibly 'undefined'.
해결: 정책에 따라 분기
const item = items.find(x => x.id === "a");
if (!item) return 0; // 또는 throw
return item.price;
또는 “없으면 에러”로 일관하고 싶다면 invariant 유틸을 두세요.
function invariant(cond: unknown, msg: string): asserts cond {
if (!cond) throw new Error(msg);
}
const item = items.find(x => x.id === "a");
invariant(item, "item not found");
const price = item.price;
asserts를 쓰면 타입이 깔끔하게 좁혀져서 !보다 유지보수성이 좋습니다.
5) 튜플/고정 길이 배열처럼 쓰지만 타입은 T[]
많은 코드가 사실상 “항상 2개가 들어오는 배열”을 다루면서 타입은 string[]로 둡니다.
function parsePair(s: string) {
const parts = s.split(":");
return { key: parts[0], value: parts[1] };
// parts[1]는 string | undefined
}
해결 A: 런타임 검증 + 튜플로 승격
function parsePair(s: string): { key: string; value: string } {
const parts = s.split(":");
if (parts.length < 2) throw new Error(`Invalid pair: ${s}`);
const [key, value] = parts as [string, string];
return { key, value };
}
해결 B: 파서 함수 자체가 Result/undefined를 반환
function tryParsePair(s: string): { key: string; value: string } | undefined {
const parts = s.split(":");
if (parts.length < 2) return;
return { key: parts[0]!, value: parts[1]! };
}
여기서 !는 길이 체크로 안전성이 확보됐을 때만 제한적으로 사용합니다.
6) “인덱스가 범위 내”라는 전제가 코드에 숨어있음
function takeNth<T>(xs: T[], n: number): T {
return xs[n];
// Type 'T | undefined' is not assignable to type 'T'.
}
해결 A: API 계약을 바꾸기(가장 안전)
function takeNth<T>(xs: T[], n: number): T | undefined {
return xs[n];
}
해결 B: 범위 체크 후 throw
function takeNth<T>(xs: T[], n: number): T {
const v = xs[n];
if (v === undefined) throw new RangeError(`Index out of range: ${n}`);
return v;
}
이렇게 하면 호출자가 “항상 존재”를 기대하는 경우에도 타입이 정합성을 갖습니다.
대규모 코드베이스에서의 적용 전략
옵션을 켜면 수백/수천 개 에러가 날 수 있습니다. 이때 중요한 건 “전부를 !로 덮지 않고”, 패턴을 수렴시켜 수정 비용을 낮추는 것입니다.
1) requireXxx / getOrThrow 계열 유틸로 중앙집중화
맵 조회, 배열 nth 접근, 환경변수 조회 등 “없으면 안 되는 값”은 한 군데로 모으세요.
export function getOrThrow<K extends PropertyKey, V>(
map: Record<K, V>,
key: K,
msg = `Missing key: ${String(key)}`
): V {
const v = map[key];
if (v === undefined) throw new Error(msg);
return v;
}
// 사용
const price = getOrThrow(priceBySku, sku).amount;
장점:
!남발 대신 정책(throw)을 일관되게 적용- 에러 메시지 품질 향상
- 이후 로깅/메트릭을 붙이기도 쉬움
2) “없을 수 있음”을 모델링하는 타입을 도입
특히 설정/캐시/룩업 테이블 계열은 Record<string, T> 대신 Partial<Record<string, T>>로 의도를 드러내면, 팀원이 코드를 읽을 때도 “undefined 처리 필요”가 자연스러워집니다.
type Cache<T> = Partial<Record<string, T>>;
const userCache: Cache<{ name: string }> = {};
const name = userCache[id]?.name;
3) 린트/리뷰 규칙: ! 사용을 제한
!는 허용하되, 바로 위 줄에 근거가 되는 체크가 존재하거나asserts유틸을 사용하도록 유도
이렇게 규칙을 두면 “컴파일만 통과하는 코드”가 늘어나는 걸 막을 수 있습니다.
tsconfig에서 같이 보면 좋은 옵션 조합
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true
}
}
exactOptionalPropertyTypes: optional 프로퍼티의 의미를 더 정확히 하여,undefined취급이 더 일관됩니다.noPropertyAccessFromIndexSignature: 인덱스 시그니처 객체에서obj.foo같은 접근을 막아, 키 기반 접근을 더 명시적으로 만듭니다.
이 조합은 초기에는 불편하지만, 일단 정착하면 “설정/데이터 경계에서 undefined가 새는 문제”를 크게 줄여줍니다.
실전 디버깅 팁: 에러를 "종류"로 묶어 처리하기
에러를 한 줄씩 고치기 시작하면 끝이 없습니다. 대신 다음 순서로 묶어 처리하면 속도가 납니다.
Record/Map조회(키 기반):getOrThrow,?.로 일괄 정리- 배열 인덱스 접근: 범위 체크/반환 타입 변경/throw 정책 결정
Object.keys루프:typedKeys유틸로 치환- 파서/스플릿 결과: 런타임 검증 후 튜플화
이 방식은 장애 대응에서 “원인별 체크리스트로 시간을 줄이는” 접근과 유사합니다. 예를 들어 인프라 장애를 원인별로 분류해 빠르게 좁혀가는 방법론은 AWS ALB 502·504 난사 - 원인별 해결 체크리스트 같은 글에서도 동일한 결로 적용됩니다. 타입 에러도 결국은 원인(패턴) 분류가 핵심입니다.
결론: noUncheckedIndexedAccess는 귀찮지만, 버그를 줄이는 비용 대비 효과가 크다
noUncheckedIndexedAccess는 단순히 “타입을 더 엄격하게” 만드는 옵션이 아니라, 인덱스 접근이라는 고위험 지점에 안전장치를 강제하는 옵션입니다. 적용 초기에 발생하는 대량 오류는 정상이며, 다음 원칙으로 접근하면 현실적으로 수습 가능합니다.
- “없으면 안 됨”은
throw/asserts로 계약을 만들고 중앙화 - “없을 수 있음”은 반환 타입/도메인 타입에 반영
Object.keys/인덱싱 패턴은 유틸로 수렴!는 최소화하고 근거가 있는 곳에만 제한적으로 사용
이 과정을 한번 거치면, 이후에는 런타임에서만 터지던 Cannot read properties of undefined류 버그가 눈에 띄게 줄고, 리팩터링 안정성이 올라갑니다.