- Published on
TypeScript 5.5 noUncheckedIndexedAccess 오류 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/프론트 공용 코드베이스에서 TypeScript의 타입 안정성을 한 단계 끌어올리려면 noUncheckedIndexedAccess 옵션을 켜는 것이 꽤 효과적입니다. 다만 TypeScript 5.5로 올리면서(혹은 기존에 잠재되어 있던 문제가 드러나면서) 배열/객체 인덱싱 지점이 줄줄이 빨갛게 변하는 경험을 하게 됩니다.
이 글에서는 noUncheckedIndexedAccess가 정확히 무엇을 바꾸는지, 왜 5.5에서 더 자주 “오류처럼” 느껴지는지, 그리고 실무에서 가장 안전하고 유지보수 가능한 해결 패턴을 정리합니다.
참고로 Next.js/Node 환경에서 타입 설정을 조정하다 보면 런타임 이슈도 같이 만나는 경우가 많습니다. ESM 전환과 경로 처리 이슈가 함께 얽혀 있다면 Node.js ESM에서 __dirname 없는 에러 해결법도 같이 확인해두면 좋습니다.
noUncheckedIndexedAccess가 바꾸는 규칙
noUncheckedIndexedAccess를 켜면, 아래 형태의 접근이 “항상 존재한다”는 가정이 깨집니다.
- 배열:
arr[i]는T가 아니라T | undefined - 객체:
obj[key]는V가 아니라V | undefined(특히 index signature,Record, 동적 키 접근)
즉, 인덱싱은 본질적으로 “키가 없을 수 있음”을 동반하므로, 타입 시스템이 그 가능성을 강제로 반영하게 됩니다.
예시: 배열 인덱싱
// tsconfig.json에서 noUncheckedIndexedAccess: true
const nums: number[] = [10, 20];
const x = nums[1];
// x: number | undefined
const y: number = nums[1];
// 오류: Type 'number | undefined' is not assignable to type 'number'.
예시: 객체 동적 키
const dict: Record<string, number> = { a: 1 };
const v = dict["b"];
// v: number | undefined
이 변화는 단순히 “타입이 더 귀찮아졌다”가 아니라, 런타임에서 실제로 undefined가 나올 수 있는 지점을 컴파일 타임에 드러내는 장치입니다.
왜 TypeScript 5.5에서 더 크게 체감될까
TypeScript는 버전이 올라가면서 제어 흐름 분석, 좁히기(narrowing), in 체크, hasOwnProperty 패턴 인식 등이 점점 정교해집니다. 그 결과 예전에는 “운 좋게” 통과하던 코드가 5.5에서 더 엄격하게 잡히거나, 반대로 특정 패턴을 쓰면 더 잘 좁혀지기도 합니다.
결론적으로 5.5가 갑자기 옵션을 바꿨다기보다는, 프로젝트가 noUncheckedIndexedAccess를 도입하거나 strictness를 강화하는 과정에서 그 영향이 크게 드러나는 경우가 많습니다.
가장 흔한 오류 패턴 6가지와 해결법
아래는 실무에서 가장 자주 마주치는 케이스를 “원인별”로 정리한 것입니다.
1) 인덱스가 범위를 벗어날 수 있는 배열 접근
문제 코드
function head<T>(arr: T[]): T {
return arr[0];
// 오류: T | undefined
}
해결 1: 반환 타입을 현실에 맞추기
function head<T>(arr: T[]): T | undefined {
return arr[0];
}
이 방식이 가장 정직합니다. 호출자가 빈 배열을 처리하게 만듭니다.
해결 2: 런타임 가드로 좁히기
function headOrThrow<T>(arr: T[]): T {
const v = arr[0];
if (v === undefined) {
throw new Error("Empty array");
}
return v;
}
“비어 있으면 예외”라는 정책이 명확한 API라면 이 패턴이 좋습니다.
해결 3: Non-null assertion !는 최후의 수단
function headUnsafe<T>(arr: T[]): T {
return arr[0]!;
}
!는 타입만 속이고 런타임 안전성을 보장하지 않습니다. 테스트/불변조건이 강하게 보장되는 구간(예: 바로 앞에서 길이 체크를 했는데 TS가 인식 못하는 경우)에만 제한적으로 쓰는 것이 좋습니다.
2) Record/딕셔너리에서 키가 없을 수 있음
문제 코드
type Prices = Record<string, number>;
function getPrice(prices: Prices, sku: string): number {
return prices[sku];
// 오류: number | undefined
}
해결 1: 기본값 제공
function getPrice(prices: Record<string, number>, sku: string): number {
return prices[sku] ?? 0;
}
해결 2: 키 존재를 강제하는 설계로 바꾸기
가능하다면 sku가 임의 문자열이 아니라 제한된 유니온이어야 합니다.
type Sku = "A" | "B" | "C";
const prices: Record<Sku, number> = {
A: 100,
B: 200,
C: 300,
};
function getPrice(prices: Record<Sku, number>, sku: Sku): number {
return prices[sku];
// 안전: number
}
이건 단순한 타입 트릭이 아니라, 데이터 모델을 “키 누락이 불가능한 구조”로 바꾸는 리팩터링입니다.
3) Map.get과 결합되며 더 자주 보이는 undefined
Map.get은 원래 V | undefined입니다. noUncheckedIndexedAccess와 결합되면 컬렉션 처리 코드에서 undefined 전파가 늘어납니다.
const m = new Map<string, { id: string }>();
function mustGet(id: string) {
const v = m.get(id);
if (!v) throw new Error("not found");
return v;
}
여기서 if (!v)는 값이 falsy일 때도 걸립니다(예: 0, ""). 객체라면 괜찮지만 일반화된 유틸은 더 엄격하게 쓰는 게 좋습니다.
function mustGet<K, V>(m: Map<K, V>, k: K): V {
const v = m.get(k);
if (v === undefined) throw new Error("not found");
return v;
}
4) Object.keys 루프에서 인덱싱이 깨짐
문제 코드
const obj = { a: 1, b: 2 };
for (const k of Object.keys(obj)) {
const v = obj[k];
// 오류 또는 v가 (number | undefined)처럼 넓어짐
}
Object.keys는 string[]을 반환하므로, k가 실제로 "a" | "b"라는 정보가 사라집니다.
해결: 타입 안전한 keys 헬퍼
function keysOf<T extends object>(o: T): Array<keyof T> {
return Object.keys(o) as Array<keyof T>;
}
const obj = { a: 1, b: 2 };
for (const k of keysOf(obj)) {
const v = obj[k];
// v: number
}
이 패턴은 noUncheckedIndexedAccess뿐 아니라, 많은 strict 옵션에서 반복적으로 도움이 됩니다.
5) 배열 find 결과를 바로 인덱싱/사용
문제 코드
type User = { id: string; name: string };
function getName(users: User[], id: string): string {
const u = users.find((x) => x.id === id);
return u.name;
// 오류: u is possibly 'undefined'
}
해결: 가드 + 명확한 정책
function getName(users: { id: string; name: string }[], id: string): string {
const u = users.find((x) => x.id === id);
if (u === undefined) return "";
return u.name;
}
혹은 예외 정책이면 throw.
6) 문자열 인덱싱(str[i])도 undefined 가능
문자열도 인덱스가 범위를 벗어나면 undefined입니다.
function firstChar(s: string): string {
return s[0];
// 오류: string | undefined
}
해결
function firstChar(s: string): string {
return s.charAt(0);
// charAt은 범위 밖이면 "" 반환
}
또는 s[0] ?? ""처럼 정책을 고정하세요.
실무에서 추천하는 “해결 우선순위”
noUncheckedIndexedAccess로 드러난 오류를 처리할 때는, 아래 우선순위가 유지보수에 유리합니다.
- 타입을 현실에 맞게 바꾸기: 반환 타입을
T | undefined로, 입력 키를 유니온으로 제한 - 런타임 가드 추가:
if (v === undefined) ...후 좁히기 - 기본값 제공:
??로 정책을 명시 - 어설션(
!,as)은 마지막: 불변조건이 코드로 증명되기 어려운 경우에만 제한적으로
이 순서를 지키면 “컴파일만 통과시키는” 방향으로 흐르지 않고, 실제 장애 가능성을 줄이는 쪽으로 코드가 정리됩니다.
tsconfig.json에서 옵션을 켤 때의 체크리스트
점진 도입을 원하면 아래처럼 운영할 수 있습니다.
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
strict와 함께 켜면 효과가 큽니다.- 다만 레거시가 크면 한 번에 켜기 어렵습니다. 패키지/폴더 단위로 TS Project Reference를 쪼개거나, CI에서 단계적으로 경고를 줄이는 전략을 고려하세요.
패턴화하면 빨리 끝난다: 재사용 유틸 3종
프로젝트에서 반복되는 해결을 유틸로 묶으면 수정량이 크게 줄어듭니다.
1) assertDefined
export function assertDefined<T>(v: T, msg = "Expected value to be defined"): asserts v is NonNullable<T> {
if (v === undefined || v === null) {
throw new Error(msg);
}
}
const arr = ["a", "b"];
const v = arr[0];
assertDefined(v);
// v: string
2) getOrThrow for Record
export function getOrThrow<K extends string, V>(obj: Record<K, V>, key: K): V {
const v = obj[key];
if (v === undefined) throw new Error(`Missing key: ${key}`);
return v;
}
키 타입을 K extends string으로 제한했기 때문에, 키가 유니온이라면 누락을 더 잘 잡아냅니다.
3) 안전한 keysOf
export function keysOf<T extends object>(o: T): Array<keyof T> {
return Object.keys(o) as Array<keyof T>;
}
마이그레이션 팁: “오류를 없애는 것”보다 “정책을 정하는 것”
noUncheckedIndexedAccess 오류를 잡는 과정은 사실상 다음 질문에 답하는 과정입니다.
- 키가 없으면 기본값을 줄 것인가?
- 없으면 예외를 던질 것인가?
- 없을 수 있으니 호출자에게
undefined를 돌려줄 것인가?
이 정책이 API 경계(예: 서비스 레이어, DB 액세스, 외부 API 응답 파싱)에서 명확해질수록, 내부 로직은 오히려 더 단순해집니다.
프론트엔드에서는 특히 서버 응답/상태 동기화 과정에서 타입이 자주 흔들립니다. RSC나 hydration 관련으로 데이터 불일치가 발생했다면 Next.js 14 RSC에서 hydration mismatch 해결법처럼 “런타임 불일치”를 먼저 잡고, 그 다음 타입 안정성을 강화하는 순서가 효율적일 때가 많습니다.
결론
TypeScript 5.5에서 noUncheckedIndexedAccess로 발생하는 오류는 대부분 “인덱싱 결과가 undefined일 수 있다”는 사실을 코드가 인정하지 않았기 때문에 생깁니다. 해결의 핵심은 !로 덮는 것이 아니라, 데이터 모델(키를 유니온으로 제한), API 정책(없으면 기본값/예외/옵셔널 반환), 그리고 반복되는 가드 패턴(유틸화)을 통해 코드베이스 전체의 안정성을 끌어올리는 것입니다.
한 번 정리해두면 이후에는 배열/딕셔너리 접근에서 생기는 잠재 버그가 컴파일 타임에 걸러져, 장애 대응 비용이 눈에 띄게 줄어듭니다.