개요
이런 걸 만들고 싶다고 가정합시다. 어떤 액션의 타입을 string
으로 받아온다고 합시다. 액션의 종류에 대해서 순회할 일도 있으므로 (값으로써 직접 가지고 있어야 하므로) 다음과 같이 배열로 만든다고 가정합시다. ('go' | 'sleep' | 'dance'
이런 식으로 직접 타입을 선언해봤자 이 타입을 기반으로 새 배열을 우리가 만들수는 없으므로.. 일단 배열을 직접 만들어야 합니다.)
const actions = ['go', 'sleep', 'dance'];
그리고 각각의 액션을 처리할 함수가 있다고 가정합시다. 또한 이 함수는 추가적인 데이터를 필요로 하기 때문에 총 인자는 2개 입니다.
function handleAction(action, data) { ... }
그리고 각각의 타입에 대한 데이터를 별도로 타입으로 정의해두고 싶다고 가정해요. (가정이 좀 많지요?) 어떤 액션에 대한 타입을 레코드 형식으로 정의한다고 가정해요.
interface ActionDataMap {
go: boolean;
sleep: string;
dance: string[];
}
위와 같이 만들어 보는 거죠. 하지만 actions
와 ActionDataMap
은 서로 영향을 주고 받을 수 없습니다. 그러니까 액션에는 'go'
, 'sleep'
, 'dance'
세 가지가 있는데, ActionDataMap
내부에서 이 세가지가 각각 존재하는지 아닌지 확인할 방법은 지금 당장엔 없습니다. 그 방법을 지금 당장 찾아봅시다.
keyof
와 lookup types 을 활용한 방법
actions
의 값들을 ActionDataMap
에 포함시키기
일단 키들은 고정되어 있고, 해당 키를 수정하려면 그냥 코드를 수정하면 되므로 as const
를 이용하여 const assertion 해줍시다.
- const actions = ['go', 'sleep', 'dance'];
+ const actions = ['go', 'sleep', 'dance'] as const;
그 다음 일단 actions 의 값 타입들을 extends 하는 타입 T 에 대해 ActionDataMap[T]
를 정의하도록 합니다.
type Check<T extends typeof actions[number]> = ActionDataMap[T];
ActionDataMap[T]
와 같이 인터페이스의 항목에 인덱스 접근하여 ([key]
로 접근하여 ) 얻는 타입을 lookup types 라고 합니다. 세부적인 타입을 활용하고 싶을 때 아주 편리한 방법이지요.
typeof actions[number]
는 'go' | 'sleep' | 'dance'
가 됩니다. 그리고 ActionDataMap[T]
를 하게 되면 해당 키를 찾아서 타입을 정의해줄 것입니다. 예를 들어 어떤 사용자가 let example: Check<'go'>;
이렇게 선언하게 되면, example
의 타입은 boolean
이 됩니다! 지금 상황에서는 T
가 ActionDataMap
의 범위에 벗어나는 값이 절대로 들어올 일이 없습니다. 왜냐하면 T
로 들어올 수 있는 'go'
, 'sleep'
, 'dance'
는 모두 ActionDataMap
의 키로 포함되어 있기 때문이죠. 에러가 일어날 가능성이 없기 때문에 에러가 나지 않습니다.
그런데 만약에 T
로 들어올 수 있는 값이 ActionDataMap
에 존재하지 않으면 어떻게 될까요? 그런 상황이 된다면 우리의 타입스크립트는 에러를 내뿜습니다! 아래를 시험해봅시다.
- const actions = ['go', 'sleep', 'dance'] as const;
+ const actions = ['go', 'sleep', 'dance', 'play'] as const;
그럼 이제 아래와 같은 에러가 발생할 것입니다.
export type Check<T extends typeof actions[number]> = ActionDataMap[T];
// Type 'T' cannot be used to index type 'ActionDataMap'. ts(2536)
좋습니다. 우리가 원하는 에러를 발생시켰습니다.
지금 상태로는 actions
에 없는 값들이 ActionDataMap
에 있어도 에러를 일으키지 않습니다. 즉 다음과 같은 코드여도 문제없이 잘 동작합니다.
const actions = ['go', 'sleep', 'dance', 'play'] as const;
interface ActionDataMap {
go: boolean;
sleep: string;
dance: string[];
+ play: number;
+ exercise: number[];
}
'exercise'
는 actions
에 없는데도 에러가 발생하지 않습니다. 왜 그럴까요? 왜냐하면 actions
에 있는 모든 키는 ActionDataMap
에 포함되어 있기 때문에 에러가 일어날 일이 없기 때문입니다. 즉 exercise
는 애초에 아웃 오브 안중이란 뜻이죠. 그렇다면 지금까지 해왔던 과정을 반대로 하기만 한다면? 서로가 서로를 포함되어야 한다, 즉 일치되어야 한다가 성립하겠군요!
ActionDataMap
의 키 값을 actions
에 포함시키기
lookup types 는 object literal 처럼 생긴 인터페이스 혹은 타입에 모두 가능합니다. 그러므로 일단 actions
를 타입으로 변환해줍니다. 이 타입은 오로지 타입 검사용으로만 사용할 예정이므로, 이름과 타입으로 실제로 사용하지 않는 용도라고 알려줍시다.
type _ActionsDumbType = {
[P in typeof actions[number]]: never;
};
그런 다음 똑같은 과정을 반복하되, Check
도 사용하지 않는다는 뜻으로 이름을 _Check
으로 바꾸고, 몸집을 키워봅시다.
- export type Check<T extends typeof actions[number]> = ActionDataMap[T];
+ type _Check<T extends typeof actions[number], U extends keyof ActionDataMap> = {
+ mapContainsLiteral: ActionDataMap[T];
+ literalContainsMapKeys: _ActionsDumbType[U];
+ } & never;
뭔가가 많이 길어졌다 생각하면 착각입니다. (착각 아닙니다). T
같은 경우는 actions
의 것들이 ActionDataMap
에 반드시 포함되어야 한다는 맥락을 보여주고, U
는 ActionDataMap
에 있는 것들이 반드시 actions
에 포함되어 있어야 한다는 맥락을 보여주고 있습니다. _Check
타입을 정의할 때 {}
로 묶는 이유는 없습니다. 그냥 mapContainsLiteral
이거랑 literalContainsMapKeys
이거 이름 붙이려고 한 겁니다. 어떤 에러가 일어나는지 더 자세히 보여주려구요. 어차피 저 _Check
제네릭 타입은 실제 사용하지 않기 때문에 & never
로 처리해놓았습니다.
아까전과 같은 코드라면 이제는 _ActionsDumbType[U]
에서 에러가 발생한다는 것을 발견하실 수 있습니다.
const actions = ['go', 'sleep', 'dance', 'play'] as const;
interface ActionDataMap {
go: boolean;
sleep: string;
dance: string[];
play: number;
exercise: number[];
}
type _ActionsDumbType = {
[P in typeof actions[number]]: never;
};
type _Check<T extends typeof actions[number], U extends keyof ActionDataMap> = {
mapContainsLiteral: ActionDataMap[T];
literalContainsMapKeys: _ActionsDumbType[U];
// Type 'U' cannot be used to index type '_ActionsDumbType'. ts(2536)
} & never;
exercise
를 삭제하면 이제 에러가 나지 않는다는 걸 확인할 수 있습니다.
const actions = ['go', 'sleep', 'dance', 'play'] as const;
interface ActionDataMap {
go: boolean;
sleep: string;
dance: string[];
play: number;
- exercise: number[];
}
type _ActionsDumbType = {
[P in typeof actions[number]]: never;
};
type _Check<T extends typeof actions[number], U extends keyof ActionDataMap> = {
mapContainsLiteral: ActionDataMap[T];
literalContainsMapKeys: _ActionsDumbType[U];
- // Type 'U' cannot be used to index type '_ActionsDumbType'. ts(2536)
} & never;
마치며
이 방법은 사실 그렇게 필요한 건 아닐수도 있습니다. 실제로 데이터를 사용할 제네릭 함수를 아래와 같이 만들면, 그에 따른 타입 검사를 해주기 때문에 위와 같이 일부러 에러를 일으키는 로직을 굳이 만들지 않아도 됩니다.
function handleAction<T extends typeof actions[number]>(action: T, data: ActionDataMap[T]) {
// ... 내용
}
하지만 actions
에 포함되지 않는 ActionDataMap
이 작성되지 않도록 강제함으로써 코드가 1% 더 깔끔해지는 효과를 볼 수 있겠지요… 아무래도 작성하시면서 느끼셨을테지만, 에러 메시지도 그렇고, 우리가 원하는 바를 구현하는 방법도 그리 직관적이지 못합니다. 타입스크립트에서 타입을 확장시키거나 좁히는 방법이 정말 한정되어 있으므로, 이것들만을 활용해 별 기괴한 짓을 다 해야 한다는 걸 점점 깨닫고 있는 요즘입니다. 타입스크립트가 어렵다는 것을 계속해서 느끼게 되네요.. 갈 길은 먼 것 같습니다.