TypeScript에는 별도의 딕셔너리나 해시맵 클래스가 없습니다. 딕셔너리는 인덱스 시그니처 { [key: string]: number }로 타입을 지정한 일반 객체, 같은 타입을 Record<string, number>로 쓴 것, 또는 Map<string, number> 중 하나입니다. 셋 모두 키로 값을 저장하며, 키의 타입, 없는 키의 타입 처리, 직렬화 방식이 다릅니다.
문자열 키와 JSON 형태의 데이터라면 Record<string, T> 객체가 일반적인 선택입니다. 문자열이 아닌 키나, 항목을 계속 추가하고 삭제하는 경우에는 Map을 쓰세요.
인덱스 시그니처(Index Signatures)
인덱스 시그니처 [key: KeyType]: ValueType은 "이 타입의 어떤 키든 이 타입의 값에 매핑된다"는 뜻입니다. 키 이름(key, name, userId)은 문서화를 위한 것일 뿐입니다. 키 타입으로는 string, number, symbol, 템플릿 리터럴 패턴, 또는 이들의 유니언을 쓸 수 있습니다.
JavaScript는 숫자 키를 문자열로 변환하므로 { [id: number]: string } 객체도 런타임에는 문자열 키를 가집니다. Object.keys({ 1: "one" })는 [ '1' ]입니다. 숫자 인덱스 시그니처는 TypeScript에서 인덱싱하는 방식만 제한합니다.
Record<K, V>
Record<string, V>는 { [key: string]: V }의 축약형입니다. string 대신 리터럴 키의 유니언을 쓰면 모든 키를 포함해야 하는 고정된 딕셔너리가 됩니다.
urls에서 staging을 빼면 컴파일 오류 TS2741(Property 'staging' is missing...)이 나므로, 유니언 키를 쓴 Record는 검사되는 조회 테이블이 됩니다. Partial은 각 값을 number | undefined로 만듭니다.
해시맵으로 쓰는 Map
Map은 모든 타입의 키를 받고, 삽입 순서를 유지하며, size가 있고, 없는 키의 타입을 정직하게 표현합니다. get은 V | undefined를 반환합니다.
키가 있는지 확인하기
확인 방법은 여러 가지이며, 모두 같은 뜻은 아닙니다.
| 확인 방법 | 대상 | 주의할 점 |
|---|---|---|
Object.hasOwn(obj, key) | 객체 | ES2022. 더 오래된 target에서는 Object.prototype.hasOwnProperty.call(obj, key) 사용 |
key in obj | 객체 | toString, constructor 같은 상속된 키에도 true |
obj[key] !== undefined | 객체 | 없는 키와 undefined로 저장된 키를 구분할 수 없음 |
if (obj[key]) | 객체 | 값이 0, "", false여도 false |
map.has(key) | Map | 나중의 map.get(key)를 좁혀 주지 않음 |
map.get(key) !== undefined | Map | 객체와 같은 undefined 주의점 |
상속된 키 문제 때문에 "constructor"나 "__proto__" 같은 사용자 입력 키를 받는 딕셔너리로 일반 객체를 쓰면 위험합니다. Map에는 그런 키가 없습니다.
없는 키의 타입 문제
인덱스 시그니처나 Record<string, T>에서는 존재하지 않는 키를 읽어도 타입이 T입니다. 컴파일러는 런타임에 undefined인 값에 메서드를 호출하는 것을 허용합니다.
컴파일러 옵션 noUncheckedIndexedAccess가 이를 해결합니다. 이 옵션을 켜면 colors["grass"]의 타입은 string | undefined가 되고, 검사하기 전까지 toUpperCase 호출은 컴파일 오류입니다. strict에 포함되지 않으므로 tsconfig.json에서 따로 켜야 합니다. 함께 쓰는 다른 플래그는 strict 모드를 참고하세요. Map에는 이런 빈틈이 없습니다. get은 항상 undefined를 포함하기 때문입니다.
추가, 삭제, 순회
delete는 인덱스 시그니처 프로퍼티에 쓸 수 있습니다. 객체 타입의 필수 이름 프로퍼티에 쓰면 컴파일 오류 TS2790 The operand of a 'delete' operator must be optional.입니다.
무엇을 쓸까
| 필요한 것 | 사용 |
|---|---|
| 문자열 키, JSON 입출력 | Record<string, T> |
| 고정된 알려진 키 집합, 모두 필수 | Record<"a" | "b", T> |
| 이름 붙은 프로퍼티와 임의의 추가 키 | 인덱스 시그니처가 있는 객체 타입 |
| 객체 키, 숫자로 유지할 숫자 키 등 문자열이 아닌 키 | Map<K, V> |
| 잦은 추가와 삭제, 또는 자주 읽는 크기 | Map<K, V> |
| 사용자에게서 오는 키 | Map<K, V>(상속된 키 없음) |
자주 묻는 질문
TypeScript에서 딕셔너리는 어떻게 만드나요?
일반 객체에 인덱스 시그니처로 타입을 지정하거나(const ages: { [name: string]: number } = {}), 같은 뜻의 Record<string, number>를 씁니다. 그런 다음 ages["ada"] = 36으로 항목을 추가합니다. 키가 문자열이 아니거나 추가와 삭제가 잦은 컬렉션이라면 new Map<string, number>()를 쓰세요.
TypeScript에 HashMap이 있나요?
그런 이름으로는 없습니다. JavaScript 내장 Map이 해시맵입니다. Map<K, V>는 키로 빠르게 조회할 수 있는 키와 값 쌍을 저장하고, 삽입 순서를 유지하며, 모든 타입의 키를 받습니다. 문자열 키라면 Record<string, V>로 타입을 지정한 일반 객체도 흔히 씁니다.
TypeScript 딕셔너리에 키가 있는지 어떻게 확인하나요?
객체 딕셔너리라면 Object.hasOwn(dict, key)나 key in dict(toString 같은 상속된 프로퍼티도 봅니다)를 쓰거나, 값을 읽어 undefined와 비교합니다. Map이라면 map.has(key)를 쓰거나, has가 나중의 get을 좁혀 주지 않으므로 map.get(key)의 결과를 직접 검사합니다.
인덱스 시그니처와 Record의 차이는 무엇인가요?
{ [key: string]: T }와 Record<string, T>는 같은 타입을 기술합니다. Record가 더 짧고, 특정 키의 유니언도 받을 수 있습니다(Record<"a" | "b", T>는 모든 키를 요구합니다). 인덱스 시그니처는 한 객체 타입 안에서 이름 붙은 프로퍼티와 함께 쓸 수 있고, { [userId: string]: User }처럼 키에 의미를 드러내는 이름을 붙일 수 있습니다.
딕셔너리에 없는 키를 읽어도 왜 오류가 나지 않나요?
기본적으로 인덱스 시그니처나 Record<string, T>에서 dict[key]의 타입은 T입니다. 런타임에 없는 키의 값이 undefined여도 그렇습니다. tsconfig.json에서 noUncheckedIndexedAccess를 켜면 타입이 T | undefined가 되어 검사를 강제합니다. strict에는 이 옵션이 포함되지 않습니다.