3.4 인덱스 시그니처와 Record
프로그래밍에서는 키-값 쌍을 동적으로 관리해야 하는 상황이 자주 등장한다. 번역 문자열 테이블, 캐시 저장소, HTTP 헤더 맵, 설정 딕셔너리 등이 그 예다. TypeScript는 이런 동적 객체를 타입 안전하게 다루기 위해 인덱스 시그니처(index signature)와 Record 유틸리티 타입을 제공한다.
인덱스 시그니처 기본 문법
인덱스 시그니처는 객체가 동적 키를 가질 수 있음을 선언하는 방법이다. [키변수명: 키타입]: 값타입 형태로 작성한다.
문자열 인덱서 [key: string]: T
interface StringDictionary {
[key: string]: string;
}
const translations: StringDictionary = {
hello: "안녕하세요",
goodbye: "안녕히 가세요",
thank_you: "감사합니다",
};
// 동적으로 키 추가 가능
translations["good_morning"] = "좋은 아침이에요";
// 존재하지 않는 키 접근 → 런타임 undefined, 컴파일 타임 string
const result = translations["unknown"]; // 타입: string (실제로는 undefined)
키 변수명(위에서 key)은 가독성을 위한 이름일 뿐, 어떤 이름이든 상관없다.
interface HttpHeaders {
[headerName: string]: string;
}
interface Cache {
[cacheKey: string]: unknown;
}
숫자 인덱서 [key: number]: T
interface NumericArray {
[index: number]: string;
}
const fruits: NumericArray = {
0: "apple",
1: "banana",
2: "cherry",
};
console.log(fruits[0]); // "apple"
console.log(fruits[3]); // undefined (런타임), string (컴파일 타임)
JavaScript 런타임에서 숫자 키는 문자열로 변환되므로, 숫자 인덱서의 값 타입은 문자열 인덱서 값 타입의 서브타입이어야 한다.
interface Mixed {
[key: string]: string | number;
[key: number]: string; // string은 string | number의 서브타입 → 정상
}
인덱스 시그니처의 제한
명시적 프로퍼티와의 타입 호환성
인덱스 시그니처가 있는 인터페이스에 명시적 프로퍼티를 추가하려면, 그 프로퍼티의 타입이 인덱서의 값 타입과 호환되어야 한다.
interface GoodMixed {
[key: string]: string | number | boolean;
name: string; // string ⊂ (string | number | boolean) → 정상
age: number; // number ⊂ (string | number | boolean) → 정상
active: boolean; // boolean ⊂ (string | number | boolean) → 정상
}
// 오류 케이스
interface BadMixed {
[key: string]: string;
// count: number; // 오류: number는 string과 호환되지 않음
// data: object; // 오류: object는 string과 호환되지 않음
}
이 제한 때문에 인덱스 시그니처의 값 타입을 unknown이나 any로 넓히는 경우가 많은데, 이는 타입 안전성을 잃는 대가다.
optional 프로퍼티 불가
인덱스 시그니처 자체에는 ?를 붙일 수 없다. 값 타입에 undefined를 포함시키는 방식으로 대신한다.
// interface Bad { [key: string]?: string; } // 문법 오류
interface AllowUndefined {
[key: string]: string | undefined; // undefined 허용
}
Record<K, V> 유틸리티 타입
Record<K, V>는 키 타입 K와 값 타입 V로 구성된 객체 타입을 생성하는 유틸리티다. 내부적으로는 mapped 타입으로 구현되어 있다.
// Record의 내부 구현
type Record<K extends keyof any, T> = {
[P in K]: T;
};
기본 사용
// 모든 string 키에 number 값
type ScoreMap = Record<string, number>;
const scores: ScoreMap = { Alice: 95, Bob: 87, Charlie: 92 };
// 특정 키들만 허용
type DayOfWeek = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
type WeeklySchedule = Record<DayOfWeek, string[]>;
const schedule: WeeklySchedule = {
mon: ["스탠드업 미팅", "코드 리뷰"],
tue: ["기획 회의"],
wed: ["스프린트 플래닝"],
thu: ["1:1 미팅"],
fri: ["회고"],
sat: [],
sun: [],
};
// 누락된 키가 있으면 컴파일 오류 → 모든 키를 강제로 정의해야 함
제네릭과 결합
// 상태 머신의 전이 테이블
type TransitionMap<S extends string> = Record<S, Partial<Record<S, boolean>>>;
type TrafficLightState = "red" | "yellow" | "green";
const transitions: TransitionMap<TrafficLightState> = {
red: { green: true }, // red → green 허용
yellow: { red: true }, // yellow → red 허용
green: { yellow: true }, // green → yellow 허용
};
인덱스 시그니처 vs Record vs Map 비교
| 특성 | 인덱스 시그니처 | Record<K, V> | Map<K, V> |
|---|---|---|---|
| 타입 레벨 존재 | 컴파일 타임 | 컴파일 타임 | 런타임 + 타입 |
| 키 타입 제한 | string | number | symbol | keyof any 서브타입 | 모든 타입 |
| 특정 키 집합 강제 | 불가 | 가능 (리터럴 유니온 사용 시) | 불가 |
| 런타임 크기 조회 | Object.keys().length | Object.keys().length | .size |
| 순서 보장 | 삽입 순서(비공식) | 삽입 순서(비공식) | 삽입 순서(공식) |
| JSON 직렬화 | 직접 가능 | 직접 가능 | 별도 처리 필요 |
| iterable | for...in | for...in | for...of |
// 인덱스 시그니처: 구조가 유연하지만 키 집합 강제 불가
interface Config {
[key: string]: unknown;
version: string; // 혼합 가능 (타입 호환 조건 충족 시)
}
// Record: 특정 키 집합 강제 가능, JSON 친화적
const permissions: Record<"read" | "write" | "admin", boolean> = {
read: true,
write: false,
admin: false,
// extra: true, // 오류: 허용되지 않은 키
};
// Map: 객체를 키로 사용 가능, 런타임 순서 보장
const userCache = new Map<string, User>();
userCache.set("user-1", { id: "1", name: "Alice", email: "a@b.com", username: "alice", isActive: true });
console.log(userCache.size); // 1
언제 무엇을 쓸까:
- 키가
string전체이고 JSON 직렬화가 필요하면 → 인덱스 시그니처 또는Record<string, V> - 특정 키 집합을 강제해야 하면 →
Record<리터럴유니온, V> - 객체를 키로 사용하거나 삽입 순서가 중요하면 →
Map
keyof typeof 패턴으로 동적 객체 키 타입 추출
런타임 객체에서 키 타입을 추출하는 강력한 패턴이다.
const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
INTERNAL_ERROR: 500,
} as const;
// typeof로 런타임 객체의 타입 추출
type HttpStatusObject = typeof HTTP_STATUS;
// { readonly OK: 200; readonly CREATED: 201; ... }
// keyof로 키 타입 추출
type HttpStatusKey = keyof typeof HTTP_STATUS;
// "OK" | "CREATED" | "NO_CONTENT" | "BAD_REQUEST" | ...
// 값 타입 추출
type HttpStatusValue = (typeof HTTP_STATUS)[HttpStatusKey];
// 200 | 201 | 204 | 400 | 401 | 403 | 404 | 500
function getStatusMessage(key: HttpStatusKey): string {
const messages: Record<HttpStatusKey, string> = {
OK: "성공",
CREATED: "생성됨",
NO_CONTENT: "내용 없음",
BAD_REQUEST: "잘못된 요청",
UNAUTHORIZED: "인증 필요",
FORBIDDEN: "접근 금지",
NOT_FOUND: "찾을 수 없음",
INTERNAL_ERROR: "서버 오류",
};
return messages[key];
}
console.log(getStatusMessage("NOT_FOUND")); // "찾을 수 없음"
객체를 열거형(enum) 대신 사용하는 패턴
const ROLES = {
USER: "user",
MODERATOR: "moderator",
ADMIN: "admin",
SUPERADMIN: "superadmin",
} as const;
type Role = (typeof ROLES)[keyof typeof ROLES];
// "user" | "moderator" | "admin" | "superadmin"
function hasPermission(userRole: Role, requiredRole: Role): boolean {
const hierarchy = Object.values(ROLES);
return hierarchy.indexOf(userRole) >= hierarchy.indexOf(requiredRole);
}
PropertyKey 타입
PropertyKey는 TypeScript 내장 타입으로 객체의 키로 사용 가능한 모든 타입을 나타낸다.
// PropertyKey의 정의
type PropertyKey = string | number | symbol;
인덱스 시그니처에 symbol 키까지 허용할 때 활용한다.
type AnyMap = Partial<Record<PropertyKey, unknown>>;
const metadata: AnyMap = {
name: "Alice",
42: "answer",
[Symbol.iterator]: function* () { yield 1; },
};
실전 예제: 다국어 번역 객체, 캐시 맵, 설정 딕셔너리
다국어 번역 시스템
type Locale = "ko" | "en" | "ja" | "zh";
type TranslationKey =
| "greeting"
| "farewell"
| "error.notFound"
| "error.unauthorized"
| "button.submit"
| "button.cancel";
// 모든 로케일과 키 조합을 강제
type TranslationTable = Record<Locale, Record<TranslationKey, string>>;
const translations: TranslationTable = {
ko: {
greeting: "안녕하세요",
farewell: "안녕히 가세요",
"error.notFound": "페이지를 찾을 수 없습니다",
"error.unauthorized": "로그인이 필요합니다",
"button.submit": "제출",
"button.cancel": "취소",
},
en: {
greeting: "Hello",
farewell: "Goodbye",
"error.notFound": "Page not found",
"error.unauthorized": "Login required",
"button.submit": "Submit",
"button.cancel": "Cancel",
},
ja: {
greeting: "こんにちは",
farewell: "さようなら",
"error.notFound": "ページが見つかりません",
"error.unauthorized": "ログインが必要です",
"button.submit": "送信",
"button.cancel": "キャンセル",
},
zh: {
greeting: "你好",
farewell: "再见",
"error.notFound": "页面未找到",
"error.unauthorized": "需要登录",
"button.submit": "提交",
"button.cancel": "取消",
},
};
function t(locale: Locale, key: TranslationKey): string {
return translations[locale][key];
}
console.log(t("ko", "greeting")); // "안녕하세요"
console.log(t("en", "button.submit")); // "Submit"
TTL 기반 캐시 맵
interface CacheEntry<T> {
value: T;
expiresAt: number; // Unix timestamp
}
class TypedCache<T> {
private store: Record<string, CacheEntry<T>> = {};
set(key: string, value: T, ttlMs: number): void {
this.store[key] = {
value,
expiresAt: Date.now() + ttlMs,
};
}
get(key: string): T | null {
const entry = this.store[key];
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
delete this.store[key];
return null;
}
return entry.value;
}
has(key: string): boolean {
return this.get(key) !== null;
}
invalidate(key: string): void {
delete this.store[key];
}
purgeExpired(): number {
let count = 0;
const now = Date.now();
for (const key of Object.keys(this.store)) {
if (now > this.store[key].expiresAt) {
delete this.store[key];
count++;
}
}
return count;
}
}
// 사용 예
const userCache = new TypedCache<User>();
userCache.set("user-1", { id: "1", name: "Alice", email: "a@b.com", username: "alice", isActive: true }, 60_000);
const cached = userCache.get("user-1"); // User | null
설정 딕셔너리
type ConfigValue = string | number | boolean | string[] | null;
interface AppConfig {
[section: string]: Record<string, ConfigValue>;
}
const config: AppConfig = {
database: {
host: "localhost",
port: 5432,
name: "myapp",
ssl: true,
poolSize: 10,
},
redis: {
host: "localhost",
port: 6379,
db: 0,
ttl: 3600,
},
auth: {
jwtSecret: "secret-key",
tokenExpiry: "7d",
allowedOrigins: ["https://app.example.com"],
},
};
function getConfig<T extends ConfigValue>(
section: string,
key: string,
defaultValue: T
): T {
const sectionConfig = config[section];
if (!sectionConfig) return defaultValue;
const value = sectionConfig[key];
return (value ?? defaultValue) as T;
}
const dbPort = getConfig("database", "port", 5432); // number
const jwtSecret = getConfig("auth", "jwtSecret", ""); // string
고수 팁: noUncheckedIndexedAccess 옵션으로 안전한 인덱스 접근
기본적으로 TypeScript는 인덱스 시그니처로 접근한 값의 타입에 undefined를 포함하지 않는다. 이는 런타임 오류로 이어질 수 있다.
const map: Record<string, number> = { a: 1 };
// noUncheckedIndexedAccess 없이: 타입이 number
const value = map["b"]; // 타입: number, 런타임: undefined
console.log(value.toFixed(2)); // 런타임 TypeError!
tsconfig.json에서 noUncheckedIndexedAccess를 활성화하면 인덱스 접근 결과에 자동으로 | undefined가 추가된다.
// tsconfig.json
{
"compilerOptions": {
"noUncheckedIndexedAccess": true
}
}
// noUncheckedIndexedAccess 활성화 후
const map: Record<string, number> = { a: 1 };
const value = map["b"]; // 타입: number | undefined
// console.log(value.toFixed(2)); // 오류: 'value'가 undefined일 수 있음
// 안전한 접근 패턴 1: optional chaining
console.log(value?.toFixed(2)); // undefined 출력
// 안전한 패턴 2: null 병합 연산자
const safe = value ?? 0;
console.log(safe.toFixed(2)); // "0.00"
// 안전한 패턴 3: 타입 가드
if (value !== undefined) {
console.log(value.toFixed(2)); // 안전
}
// 안전한 패턴 4: hasOwnProperty 검사
function safeGet<T>(obj: Record<string, T>, key: string): T | undefined {
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
}
배열 접근에도 적용됨
// noUncheckedIndexedAccess 활성화 시
const arr = [1, 2, 3];
const first = arr[0]; // 타입: number | undefined
const last = arr[arr.length - 1]; // 타입: number | undefined
// 방어적 코드
function safeFirst<T>(arr: T[]): T | undefined {
return arr[0]; // noUncheckedIndexedAccess에서 안전
}
// 인덱스 접근 없이 구조 분해 사용
const [head, ...tail] = arr;
console.log(head); // 타입: number (undefined 아님, 구조 분해는 다르게 처리)
정리 표
| 도구 | 문법 | 특징 | 적합한 상황 |
|---|---|---|---|
| 문자열 인덱서 | [key: string]: T | 모든 string 키 허용 | HTTP 헤더, 임의 딕셔너리 |
| 숫자 인덱서 | [key: number]: T | number 키, 배열 유사 | 배열 유사 구조 |
Record<string, V> | Record<string, V> | 인덱서와 동일, 더 간결 | 일반 키-값 맵 |
Record<K, V> (리터럴) | Record<"a" | "b", V> | 특정 키 강제 | 열거형 유사 객체 |
Map<K, V> | 런타임 클래스 | 객체 키, 순서 보장 | 복잡한 키, 순서 중요 |
keyof typeof | keyof typeof obj | 런타임 객체에서 키 추출 | const 객체 기반 타입 |
noUncheckedIndexedAccess | tsconfig 옵션 | 인덱스 접근에 | undefined | 타입 안전성 최대화 |
다음 장에서는...
3.5 함수 타입에서는 TypeScript에서 함수의 타입을 정의하는 다양한 방법을 다룬다. 함수 오버로드, 콜백 타입, this 파라미터, 그리고 공변/반공변 개념을 통해 함수 타입의 호환성을 깊이 있게 이해할 수 있다.