2026-03-15

BuildIndex: 배열 최대 개수로 유효한 인덱스 유니온 타입 만들기

개요 / 배경

배열의 최대 개수가 정해진 경우, 유효한 인덱스를 타입으로 표현하고 싶은 상황이 있다.

직접 작성하면 최대 개수가 바뀔 때마다 타입도 수동으로 수정해야 한다.

const MAX_COUNT = 3;
// 직접 작성 — 최대 개수가 바뀌면 타입도 수동으로 수정해야 함
type RangeIndex = 0 | 1 | 2;

number로 처리하면 어떨까?

type IndexedFields = `items.${number}`;
type Fields = "default" | IndexedFields;
declare function onSelect(field: Fields): void;
onSelect("items.999"); // 타입 에러 없음 — 범위 초과 허용
onSelect(""); // 자동완성: 'default' 만 표시 (items.0, items.1 ... 제안 없음)

두 가지 문제가 있다.

첫째, number는 너무 넓어서 범위를 초과한 인덱스도 타입 에러 없이 통과한다.

둘째, IDE 자동완성이 제대로 동작하지 않는다.

  • TypeScript는 자동완성 후보를 열거할 때 유한한 문자열 리터럴 타입만 표시
  • items.${number}는 가능한 값이 무한하기 때문에 후보 목록에 올라오지 않고 유니온에 함께 있는 'default'만 표시

동작 원리

BuildIndex 타입

type BuildIndex<
N extends number,
T extends number[] = []
> = T["length"] extends N ? T[number] : BuildIndex<N, [...T, T["length"]]>;

재귀적으로 배열 T를 하나씩 늘려가며, 길이가 N에 도달하면 T[number]로 유니온을 반환한다.

BuildIndex<3> 동작 과정:

1. T = [] → T['length'] = 0, 0 extends 3? No → BuildIndex<3, [0]>
2. T = [0] → T['length'] = 1, 1 extends 3? No → BuildIndex<3, [0, 1]>
3. T = [0, 1] → T['length'] = 2, 2 extends 3? No → BuildIndex<3, [0, 1, 2]>
4. T = [0, 1, 2] → T['length'] = 3, 3 extends 3? Yes → T[number] = 0 | 1 | 2

결과: 0 | 1 | 2

핵심 메커니즘

표현의미
T['length']배열 T의 현재 길이 (숫자 타입)
[...T, T['length']]T에 현재 길이값을 추가한 새 배열
T[number]배열 T의 모든 요소를 유니온으로 추출

배열 [0, 1, 2]에서 T[number]0 | 1 | 2가 된다. 이 트릭 덕분에 배열이 곧 누적된 인덱스 목록이 되고 유니온으로 꺼낼 수 있다.

활용 예시

const MAX_COUNT = 3;
type RangeIndex = BuildIndex<typeof MAX_COUNT>;
// → 0 | 1 | 2
type IndexedFields = `items.${RangeIndex}`;
// → 'items.0' | 'items.1' | 'items.2'
type Fields = "default" | IndexedFields;
declare function onSelect(field: Fields): void;
onSelect("items.2"); // ✅
onSelect("items.3"); // ❌ 타입 에러 — MAX 초과

IDE 자동완성도 동작한다. 'items.'까지 입력하면 0, 1, 2를 자동완성으로 제안받을 수 있다.

최대 개수 변경 시 타입이 자동으로 동기화된다.

const MAX_COUNT = 5;
type RangeIndex = BuildIndex<typeof MAX_COUNT>;
// → 0 | 1 | 2 | 3 | 4 (자동으로 확장)

타입을 직접 수정할 필요 없이 상수 하나만 바꾸면 된다.

주의사항

BuildIndex는 재귀 타입이라 N이 커질수록 TypeScript가 처리해야 할 타입 인스턴스가 많아진다. N이 1000 이상이 되면 다음 에러가 발생한다.

Type instantiation is excessively deep and possibly infinite. (2589)

실용적인 범위(수십~수백)에서는 문제없지만, 매우 큰 N이 필요한 경우에는 이 타입 유틸리티를 사용할 수 없다.

Playground 비교

type BuildIndex<
N extends number,
T extends number[] = []
> = T["length"] extends N ? T[number] : BuildIndex<N, [...T, T["length"]]>;
const MAX_COUNT = 5;
type RangeIndex = BuildIndex<typeof MAX_COUNT>;
// 0 | 1 | 2 | 3 | 4
type IndexedFieldsSafe = `items.${RangeIndex}`;
type IndexedFieldsUnsafe = `items.${number}`;
type FieldsSafe = "default" | IndexedFieldsSafe;
type FieldsUnsafe = "default" | IndexedFieldsUnsafe;
declare function onSelectSafe(field: FieldsSafe): void;
declare function onSelectUnsafe(field: FieldsUnsafe): void;
onSelectSafe("items.4"); // ✅
onSelectSafe("items.5"); // ❌ 타입 에러
onSelectUnsafe("items.999"); // 타입 에러 없음
// 자동완성 비교
onSelectSafe(""); // → 'default', 'items.0', 'items.1', 'items.2', 'items.3', 'items.4' 제안
onSelectUnsafe(""); // → 'default' 만 제안

TypeScript Playground에서 직접 확인하기