[typescript] readonly 배열의 includes 인수 범위를 확장하기 (Type Predicates)

개요

이 글의 제목을 정하는 데 좀 고민이 많이 들었습니다.. 뭔가 복합적인 문제인 듯 한데, 좀 길게 풀어서 설명하자면, const array = ['a', 'b', 'c'] as const 와 같은 readonly 배열에서 'a' | 'b' | 'c' 타입이 아닌 const needle: string = 'needle' 와 같은 string 타입을 이용해 array.includes(needle) 과 같이 사용하고 싶은데, 타입 문제 때문에 그렇지 못해서 수정하고자 쓰는 글입니다.

이 글에서는 타입을 설명할 때 ‘좁다’, ‘넓다’와 같은 말을 쓸 것입니다. 포함된다, 확장된다 라고도 표현할 수 있겠지만, 제 머릿속에서 딱 떠오르지가 않아서요. 헤헤.

이 글은 독자분들이 어느정도 타입스크립트의 기본 개념을 알고 있다는 전제하에 작성되었습니다. 타입을 추론(Inference) 하는 과정을 몸에 좀 익히고 있다면 문제없이 글을 읽으실 수 있을 것입니다.

문제

자 일단, 용어를 어떻게 해야 하는지 좀 고민스러운데, const array, readonly array 등 일맥상통하는 용어들이 있지만, 일단은 readonly 배열이라고 명명하겠습니다. 좀 더 정확히 이야기하면 const assertions 를 통해서 만든 배열이 readonly 배열이 됩니다. 이 배열은 변경되지 않는 부분을 확실히 함으로써 그 자체로 타입이 됩니다! 바로 아래에서 그 타입을 확인해보세요. vscode 에서 작성하고 변수명에 마우스를 올린다면 실제로 계산된 타입이 보입니다. (주석)

const colors = ['red', 'green', 'blue'] as const; // readonly ["red", "green", "blue"]
type ColorType = typeof colors[number] // "red" | "green" | "blue"
function doWithColor(color: ColorType): void {
  // do something
}

ColorType 타입을 이용해서 우리는 각종 함수에 enum 처럼 사용할 수 있습니다. 아주 편리하지요! 하지만 이제 다음과 같은 문제가 생깁니다.

만약 사용자가 어떤 값(userInput)을 입력해왔다고 합시다. 이 값은 뭐일지 모릅니다. 일단 string 인 건 확실하다고 가정합시다. 하지만 그렇다고 해도 colors 에서의 includes 를 사용할 수는 없습니다. 왜냐하면 colors 에는 애초에 'red', 'green', 'blue' 만 들어갈 수가 있어서 이것들로만 있는지 없는지 검사할 수 있기 때문입니다!

const userInput: string = getUserInput();
if (colors.includes(userInput)) { // 에러!
  // 올바른 userInput 이다!
  doWithColor(userInput);
} else {
  // 올바르지 않은 userInput 이다!
}
Argument of type 'string' is not assignable to parameter of type '"red" | "green" | "blue"'. ts(2345)

그렇다면, 값은 똑같되 타입만 다른 string 배열을 만들어서 포함되는지 확인하면 되잖아요?

const userInput: string = getUserInput();
const typeCheck: string[] = [...colors];
if (colors.includes(userInput)) {
  // 올바른 userInput 이다!
  doWithColor(userInput); // 에러!
} else {
  // 올바르지 않은 userInput 이다!
}
Argument of type 'string' is not assignable to parameter of type '"red" | "green" | "blue"'.ts(2345)

하지만 어림도 없죠. 에러가 발생합니다. 왜냐하면 여전히 userInputstring 이기 때문에, doWithColor(userInput) 입장에서 userInput"red" | "green" | "blue" 뿐 아니라 "purple" 과 같은 이상한 값이 들어올 가능성이 있다고 판단합니다. 저 if 문을 통과했다고 하더라도 말입니다!!

이런 문제를 Type Predicates (타입 서술?)를 활용하여 해결하도록 하겠습니다.

Type Predicates

레퍼런스를 먼저 보는 게 더 마음이 편할 것입니다. 우리는 Type Predicates 를 통해 타입을 좁혀줄 수 있습니다! 자, 타입을 좁힌다는 것이 무슨 뜻일까요? 아래 이미지를 봅시다.

타입의 범위

string"red" | "green" | "blue" 를 비교해봅시다. string 은 아주 다양한 것들을 포함할 수 있습니다. 다만 "red" | "green" | "blue" 는 3개 밖에 없습니다. "purple"ColorType 이 될 수 없고, 오직 string 만 가능합니다. 반면 모든 ColorTypestring 입니다. 직관적으로 표현하자면 string 은 망망대해와 같은 넓고 큰 타입이고, 반면 ColorType 은 가능한 것이 3개 밖에 없는 좁은 타입입니다. 우리가 Type Predicates 를 한다는 뜻은, 본래 string 이었던 userInput 값을, 우리가 직접 검사를 해서 그 값의 타입을 ColorType으로 간주하게 한다는 것입니다!

타입 Predicates 를 사용하려면 우선 함수를 만들어야 합니다. 그 함수를 user-defined type guard(사용자정의 타입 가드) 라고 명명하겠습니다. 이 함수는 리턴 타입이 boolean 이어야 하는데, boolean 대신 : input is ColorType 과 같이 is 키워드를 넣어서 정의해야 합니다!

export function contains<T extends string>(
  list: ReadonlyArray<T>,
  value: string,
): value is T {
  return list.some((item) => item === value);
}

위 함수는 우선 두 가지의 값을 받습니다. list 는 readonly 배열이고, value 는 검사하고자 하는 값입니다. ColorTypestring 안에 있는 좁은 타입이므로 stringextends 한다고 볼 수 있습니다. 그래서 문제가 없습니다. 그래서 list 안에 하나라도 value 와 일치하는 것이 있다면, true 를 반환하고, 그렇지 않다면 false 를 반환하는 간단한 함수입니다.

이 함수에서 value is T 의 의미는, 이 contains 함수의 결과가 참이라면 valueT 타입으로 간주해도 좋다는 뜻입니다. 우리가 실제로 실행할 때에는 T 가 ColorType 으로 inference 되므로, 결과적으로 타입이 좁혀지면서 아래 코드는 문제없이 동작하게 됩니다.

const userInput: string = getUserInput();
const typeCheck: string[] = [...colors];
if (contains(colors, userInput)) {
  // 올바른 userInput 이다!
  doWithColor(userInput);
} else {
  // 올바르지 않은 userInput 이다!
}

활용

필자는 콤마(,)로 구분된 길다란 문자열을 나눠서 어떤 readonly 배열에 있는 유효한 값들만 모으는 함수를 만들기 위해 이 고생을 했습니다. (매번 선형 탐색을 하기 때문에 커다란 자료를 다룬다면 Set 등의 자료구조를 고려해야 합니다.)

export function contains<T extends string>(
  list: ReadonlyArray<T>,
  value: string,
): value is T {
  return list.some((item) => item === value);
}

export function parseRestrictedArray<T extends string>(
  value: string,
  list: ReadonlyArray<T>,
): T[] {
  const arr = value.split(',');
  const result: T[] = [];
  arr.forEach((item) => {
    if (contains(list, item)) {
      result.push(item);
    }
  });
  return result;
}

한계

이 Type Predicates 에서 유의해야 할 점은, 타입을 좁히는 것 밖에 되지 않는다는 점입니다. 타입을 넓히는 것은 as 를 활용한 Type Assertion 을 고려해야 합니다.

마치며

마이크로소프트 타입스크립트 레퍼런스들은 뭔가 빈약한 기분이에요… 있을 거 다 있는 것 같으면서도…

답글 남기기

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

Scroll to top