4.2 색인 가능 타입

Last updated 10 months ago

동적인 색인을 표현하는 색인 가능 타입에 대해 다룬다.

앞서 다룬 예시들은 모두 코드 작성 시점에 속성 이름이 알려져 있었다. 하지만 코드의 실행 시점에서만 알 수 있는 이름의 동적 속성을 갖는 타입은 어떻게 표시해야 할까?

const users: = [
{ name: '안희종', height: 176, favoriteLanguage: 'TypeScript' },
{ name: '이방인', height: 42 }
];
interface NameHeightMap {
// ??
}
const nameHeightMap: NameHeightMap = {};
users.map(user => {
nameHeightMap[user.name] = user.height;
});
console.log(userHeightMap) // { '안희종': 176, 'Stranger': 42 }

위 코드의 nameHeightMap임의의 유저 목록을 받아 유저의 이름을 키로, 유저의 신장을 값으로 갖는 매핑이다. 이 때 이 객체의 키들은 임의의 유저 이름이므로 코드를 작성하는 시점에는 모든 가능한 키를 나열하는 것이 불가능하다. 예를 들어 위 타입을 아래와 같이 정의한다고 해 보자.

interface NameHeightMap {
안희종: number;
이방인: number;
}

이 경우 이후 users 값에 { name: '뉴페이스', height: 777 } 등의 유저가 추가되는 경우를 제대로 처리하지 못 할 것이다. 또한 실제로는 users 와 같은 정보를 실행 시간에 서버로부터 얻어오는 등의 경우가 많은데, 이런 경우 역시 커버할 수 없다. 이럴 때 필요한 것이 바로 색인 가능 타입(indexable type)이다.

색인 시그니쳐

색인 가능 타입을 이용해 색인 가능한(indexable) 객체의 타입을 정의할 수 있다. 색인 가능 타입을 정의하기 위해서는 색인에 접근할 때 사용하는 기호인 대괄호([])를 이용해 객체의 색인 시그니쳐(index signature)를 적어줘야 한다.

예를 들어 위의 NameHeightMap 인터페이스는 색인 가능 타입을 사용해 아래와 같이 적을 수 있다.

interface NameHeightMap {
[userName: string]: number | undefined;
}

위 정의는 다음과 같이 읽는다.

  • NameHeightMap 타입의 값을

  • 임의의 string 타입 값 userName 으로 색인한 값 ([userName: string] → 인덱스 시그니쳐)

  • nameHeightMap[userName]number 또는 undefined 타입의 값이다. (: number | undefined)

위 예제에선 색인된 값이 number 가 아닌 number | undefined 타입을 가지는 것에 유의하라. nameHeightMap이 모든 문자열을 키로 갖고 있다는 보장이 없으므로 nameHeightMap['없는 유저'] 따위의 값은 undefined 일 수 있기 때문이다.

이 경우 색인된 값을 number 타입의 값으로 사용하고 싶다면 먼저 undefined인지 여부를 체크해줘야 한다.

const h = nameHeightMap['안희종']; // 이 시점에서 h의 타입은 number | undefined
if (h !== undefined) {
// 이 시점에서 h의 타입은 number
console.log(h.toString()); // ok
}

색인과 타입

색인의 타입으로는 문자열 또는 숫자만이 사용 가능하다. 이 때 주의해야 할 점은 만약 문자열 색인과 숫자 색인이 모두 존재하는 경우, 숫자로 색인 된 값의 타입은 문자열로 색인 된 값 타입의 서브타입이어야 한다는 것이다.

즉, 아래 예제에서 BA의 서브타입이어야 한다.

inteface Mixed<A, B> {
[stringIndex: string]: A;
[numberIndex: number]: B;
}

이 때 “BA의 서브타입이다”는 말의 의미는 “B 타입의 모든 값은 A 타입에도 속한다” 정도로 이해할 수 있다. 예를 들어, 모든 정수를 나타내는 타입 Int와 모든 숫자를 나타내는 타입 Num이 존재한다고 하자. 모든 정수는 숫자이므로 (즉 Int 타입의 모든 값을 Num 타입의 값으로도 사용할 수 있으므로) IntNum의 서브타입이다.

이러한 제약이 존재하는 이유는 자바스크립트 색인의 동작 방식 때문이다. 자바스크립트 코드에서 객체의 색인에 접근할 때, 내부적으로는 색인의 toString() 메소드를 호출해 문자열로 변형된 값을 색인으로 사용한다. 예를 들어 1.toString() === '1' 이므로 obj[1] 이라고 적은 코드는 실제로는 obj['1']와 동일하다.

이 때, 만약 다음 ErrorProne 타입과 같이 숫자로 색인 된 값의 타입(boolean)이 문자열로 색인 된 타입(number)의 서브타입이 아닌 경우가 허용된다고 가정해보자.

interface ErrorProne {
[str: string]: number;
[num: number]: boolean;
}
let errorProne: ErrorProne = {
'abc': 3,
3: true
};
errorProne[3];

가장 아래 줄을 보면, 3이라는 색인은 숫자 타입이므로 타입 시스템은 errorProne[3]의 타입이 boolean일 것이라 추측할 것이다. 하지만 위에서 언급한 색인의 동작 방식에 의해 실제로 해당 값은 errorProne['3']과 같고, 이는 문자열 색인으로 접근한 number 타입의 값이다. 타입 시스템이 알고 있는 정보(boolean)와 실제 상황(number) 이 달라지는 것이다.

따라서 타입스크립트는 이런 코드를 작성하는 것을 허용하지 않고, error TS2413: Numeric index type 'boolean' is not assignable to string index type 'number'. 와 같은 에러를 발생시킨다. 숫자 색인으로 접근한 타입 boolean을 문자열 색인으로 접근한 타입 number에 할당할 수 없다는 의미다.

비슷한 이유로, 문자열 색인 시그니처가 존재한다면 그 외 모든 속성의 값 타입은 문자열 색인으로 접근한 값의 타입의 서브타입이여야 한다. 모든 속성 접근은 (user.name === user['name'] 이므로) 결국 문자열 색인 접근의 특수한 케이스이기 때문이다. 아래와 같은 선언은 타입 에러를 발생시킨다.

interface User {
[randomProp: string]: number;
name: string;
}
// error TS2411: Property 'name' of type 'string' is not assignable to string index type 'number'.

읽기 전용 색인

색인 역시 읽기 전용으로 선언할 수 있다. 객체 타입, 인터페이스에서의 readonly의 동작과 마찬가지로 readonly로 선언된 색인의 값은 재할당이 불가능하다.

interface ReadonlyNameHeightMap {
readonly [name: string]: height;
}
const m: ReadonlyNameHeightMap = { '안희종': 176 };
m['안희종'] = 177; // error TS2542: Index signature in type 'ReadonlyNameHeightMap' only permits reading.

색인 가능 타입의 사용예

색인 가능 타입을 사용하는 가장 간단하면서도 유용한 인터페이스 중 하나로 Array 인터페이스를 꼽을 수 있다. 만약 색인 가능 타입이 없이 T 타입의 원소를 갖는 Array 인터페이스를 작성한다면 대략 아래와 같은 식으로 모든 색인에 대한 타입을 일일이 정의해야 할 것이다.

interface Array<T> {
length: number;
0?: T;
1?: T;
/* ... */
Number.MAX_SAFE_INTEGER?: T;
/* 메소드 정의 */
}

인덱스 타입을 이용하면 위 코드를 다음처럼 간결하게 대체할 수 있다.

interface Array<T> {
length: number;
[index: number]?: T;
/* 메소드 정의 */
}