[Typescript] 인터페이스의 키 목록을 string 배열과 일치하도록 강제하기

개요

이런 걸 만들고 싶다고 가정합시다. 어떤 액션의 타입을 string 으로 받아온다고 합시다. 액션의 종류에 대해서 순회할 일도 있으므로 (값으로써 직접 가지고 있어야 하므로) 다음과 같이 배열로 만든다고 가정합시다. ('go' | 'sleep' | 'dance' 이런 식으로 직접 타입을 선언해봤자 이 타입을 기반으로 새 배열을 우리가 만들수는 없으므로.. 일단 배열을 직접 만들어야 합니다.)

const actions = ['go', 'sleep', 'dance'];

그리고 각각의 액션을 처리할 함수가 있다고 가정합시다. 또한 이 함수는 추가적인 데이터를 필요로 하기 때문에 총 인자는 2개 입니다.

function handleAction(action, data) { ... }

그리고 각각의 타입에 대한 데이터를 별도로 타입으로 정의해두고 싶다고 가정해요. (가정이 좀 많지요?) 어떤 액션에 대한 타입을 레코드 형식으로 정의한다고 가정해요.

interface ActionDataMap {
  go: boolean;
  sleep: string;
  dance: string[];
}

위와 같이 만들어 보는 거죠. 하지만 actionsActionDataMap 은 서로 영향을 주고 받을 수 없습니다. 그러니까 액션에는 '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 이 됩니다! 지금 상황에서는 TActionDataMap 의 범위에 벗어나는 값이 절대로 들어올 일이 없습니다. 왜냐하면 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 에 반드시 포함되어야 한다는 맥락을 보여주고, UActionDataMap 에 있는 것들이 반드시 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% 더 깔끔해지는 효과를 볼 수 있겠지요… 아무래도 작성하시면서 느끼셨을테지만, 에러 메시지도 그렇고, 우리가 원하는 바를 구현하는 방법도 그리 직관적이지 못합니다. 타입스크립트에서 타입을 확장시키거나 좁히는 방법이 정말 한정되어 있으므로, 이것들만을 활용해 별 기괴한 짓을 다 해야 한다는 걸 점점 깨닫고 있는 요즘입니다. 타입스크립트가 어렵다는 것을 계속해서 느끼게 되네요.. 갈 길은 먼 것 같습니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다

Scroll to top