Logo

ts-reset: 타입스크립트한테 뒤통수 맞지 않기

우리가 버그가 없는 코드를 작성하기 위해서 사용하는 타입스크립트도 알고 보면 은근히 버그 투성이라는 것을 혹시 알고 계신가요?

이번 포스팅에서는 많은 개발자들이 타입스크립트로 코딩하다가 겪게 되는 황당한 경우에 대해서 알아보고, 이러한 타입스크립트의 버그를 깔끔하게 고쳐주는 ts-reset이라는 라이브러리를 소개해드리려고 합니다.

배열의 includes() 함수의 배신

배열에 특정 요소가 존재하는지를 확인하기 위해서 includes() 함수를 많이 사용하시죠? 그런데 배열을 상대로 as const를 사용하여 읽기 전용(read-only)으로 만들어주면 includes() 함수를 사용할 때 황당한 일을 겪을 수 있는데요.

예를 들어, 숫자 1, 2, 3이 들어있는 읽기 전용 배열을 상대로 숫자 4가 들어있는지 물어보면 다음과 같이 어처구니 없는 타입 에러가 발생합니다.

const numbers = [1, 2, 3] as const;

numbers.includes(1); // true
numbers.includes(2); // true
numbers.includes(3); // true
numbers.includes(4); // 🤯 Argument of type '4' is not assignable to parameter of type '1 | 2 | 3'.ts(2345)
//      ^? (method) ReadonlyArray<1 | 2 | 3>.includes(searchElement: 1 | 2 | 3, fromIndex?: number | undefined): boolean

타입스크립트가 추론한 includes() 함수의 타입을 보면 인자로 123 중 하나만 받도록 되어있는데요. 이럴 거면 뭐하려 includes() 함수를 쓰나요? 항상 참이 나올꺼잖아요.

논리적으로 생각을 해보면 includes() 함수에 123 외에 다른 숫자도 넘길 수 있어야 합니다.

그런데 놀랍게도 ts-reset 라이브러리를 사용하면 이 문제가 아주 자연스럽게 고쳐집니다.

const numbers = [1, 2, 3] as const;

numbers.includes(1); // true
numbers.includes(2); // true
numbers.includes(3); // true
numbers.includes(4); // 🎉 false
//      ^? (method) ReadonlyArray<1 | 2 | 3>.includes(searchElement: number, fromIndex?: number | undefined): boolean (+1 overload)

배열의 indexOf()와 lastIndexOf()의 배신

배열의 indexOf()lastIndexOf() 함수도 includes()와 비슷한 문제가 있는데요. 읽기 전용 배열을 상대로 호출하면 배열에 들어있지 않는 값을 인수로 넘길 경우 예상치 못한 타입 에러가 발생합니다.

const numbers = [1, 2, 3] as const;

numbers.indexOf(1); // 0
numbers.indexOf(2); // 1
numbers.indexOf(3); // 2
numbers.indexOf(4); // 😑 Argument of type '4' is not assignable to parameter of type '1 | 2 | 3'.ts(2345)
//      ^? (method) ReadonlyArray<1 | 2 | 3>.indexOf(searchElement: 1 | 2 | 3, fromIndex?: number | undefined): number

numbers.lastIndexOf(1); // 0
numbers.lastIndexOf(2); // 1
numbers.lastIndexOf(3); // 2
numbers.lastIndexOf(4); // 😑 Argument of type '4' is not assignable to parameter of type '1 | 2 | 3'.ts(2345)
//      ^? (method) ReadonlyArray<1 | 2 | 3>.lastIndexOf(searchElement: 1 | 2 | 3, fromIndex?: number | undefined): number

이 문제 역시 프로젝트에 ts-reset 라이브러리만 설정해주면 알아서 교정이 됩니다.

배열의 filter() 함수의 배신

배열에서 특정 요건을 만족하는 요소를 추출하기 위해서 filter() 함수를 많이 사용하는데요. 많은 개발자들이 filter() 함수를 사용해서 undefinednull을 제거하더라도 타입에는 변함이 없다는 것을 깨닫고 당황하게 되죠.

const tags = ["work", "life", undefined, "travel", null];
const validTags = tags.filter(Boolean); // ["work", "life", "travel"]
//    ^? 😵 const validTags: (string | null | undefined)[]

filter() 함수의 반환 타입에서 undefinednull을 제거하려면 다음과 같이 type predicate을 사용해야하는데요.

const tags = ["work", "life", undefined, "travel", null];
const validTags = tags.filter((tag): tag is string => !!tag); // ["work", "life", "travel"]
//    ^? const validTags: string[]

하지만 코딩하면서 매번 이렇게 해주는 게 상당히 번거로울 수 있으며 코드도 불필요하게 지저분해질 수 있죠. 사실 논리적으로 따져보면 validTags 배열에는 undefinednull이 남아있을 확률이 없는데 정말 이상하죠.

ts-reset 라이브러리를 사용하면 type predicate 없이도 의도했던 대로 타이핑이 되는 신기한 경험을 하실 수 있으실 거에요.

const tags = ["Work", "Life", undefined, "Travel", null];
const validTags = tags.filter(Boolean); // ["work", "life", "travel"]
//    ^? ✨ const validTags: string[]

세트의 has() 함수의 배신

Set은 데이터를 중복없이 저장하기 위해서 사용되는 자료구조이며, Sethas() 함수는 어떤 값이 현재 세트에 들어있는지를 확인하기 위해서 쓰입니다. 그런데 이 Sethas() 함수에 현재 세트에 들어있지 않은 값을 넘기면 다음과 같이 난감한 타입 에러와 마주하게 되는데요.

const charSet = new Set(["A", "B", "C", "B", "A"] as const);

charSet.has("A"); // true
charSet.has("B"); // true
charSet.has("C"); // true
charSet.has("D"); // 😬 Argument of type '"D"' is not assignable to parameter of type '"A" | "B" | "C"'.ts(2345)
//      ^? (method) Set<"A" | "B" | "C">.has(value: "A" | "B" | "C"): boolean

아니 현재 세트가 담고있는 값만 인수로 넘길 수 있다면 뭐하러 has() 함수를 사용할까요? 타입스크립트가 허용하는 값만 넘긴다면 어차피 항상 true가 반환될텐데요.

이 문제도 ts-reset 라이브러리만 설정해주면 알아서 해결이 됩니다.

const charSet = new Set(["A", "B", "C", "B", "A"] as const);

charSet.has("A"); // true
charSet.has("B"); // true
charSet.has("C"); // true
charSet.has("D"); // 🎉 false
//      ^? (method) Set<"A" | "B" | "C">.has(value: string): boolean

맵의 has() 함수의 배신

Map은 키와 값의 쌍의 데이터를 저장하기 위해서 사용되는 자료구조이며, Maphas() 함수는 어떤 키가 현재 맵에 들어잇는지를 확인하기 위해서 쓰입니다. 그런데 이 Maphas() 함수에 현재 세트에 들어있지 않은 키를 넘기면 Set과 유사한 타입 에러가 발생하는데요.

const charSet = new Map([
  ["A", "a"],
  ["B", "b"],
  ["C", "c"],
] as const);

charSet.has("A"); // true
charSet.has("B"); // true
charSet.has("C"); // true
charSet.has("D"); // 😮‍💨 Argument of type '"D"' is not assignable to parameter of type '"A" | "B" | "C"'.ts(2345)
//      ^? (method) Map<"A" | "B" | "C", "a" | "b" | "c">.has(key: "A" | "B" | "C"): boolean

has() 함수에 이미 맵에 들어있는 키만 넘길 수 있다면 굳이 has() 함수를 사용할 이유가 없겠죠?

이 버그도 ts-reset 라이브러리의 도움을 받아 고칠 수 있습니다.

const charSet = new Map([
  ["A", "a"],
  ["B", "b"],
  ["C", "c"],
] as const);

charSet.has("A"); // true
charSet.has("B"); // true
charSet.has("C"); // true
charSet.has("D"); // 🥳 false
//      ^? (method) Map<"A" | "B" | "C", "a" | "b" | "c">.has(value: string): boolean

JSON.parse() 함수의 위험성

타입스크립트에서 JSON 내장 객체의 parse() 함수가 any 타입을 반환한다는 것을 알고 계셨나요? 이것은 타입 안전한(type-safe) 코드를 작성하는데 큰 문제가 될 수 있는데요.

예를 들어, 다음 예제 코드를 보면 JSON.parse()의 반환 결과를 user 변수에 저장하고 있는데요. user 변수의 타입이 any가 되기 때문에 아무 속성이나 막 접근해도 아무런 타입 에러가 발생하지 않습니다.

const user = JSON.parse(/* 어떤 문자열 */);
//    ^? const user:any

console.log(user.abc); // 🤨 타입 에러가 발생하지 않음
console.log(user.abc.xyz()); // 🤨 타입 에러가 발생하지 않음

JSON.parse() 함수의 반환 타입이 any 대신에 unknown이었다면 어땠을까요? 우리는 다음과 같이 적절히 type guard를 사용해서 좀 더 타입 안전한 코드를 작성했을 것입니다. 이를 통해서 해당 객체에 존재하는 속성에만 접근할 수 있는 것이지요.

const user = JSON.parse(/* 어떤 문자열 */);
//    ^? const user:unknown

if (typeof user === "object" && user && "email" in user) {
  console.log(user.email); // 타입 안전 ✅
}

ts-reset 라이브러리는 위와 같이 JSON.parse() 함수가 unknown 타입을 반환해도록 해줍니다.

자바스크립트에서 JSON 데이터를 다룰 때 사용되는 JSON 내장 객체에 대해서는 별도 포스팅에서 자세히 다루고 있으니 참고 바랍니다.

fetch() 함수의 위험성

fetch() 함수를 사용할 때도 JSON.parse() 함수를 사용할 때와 비슷한 문제를 겪을 수 있는데요. json() 함수를 사용해서 읽어낸 응답 데이터가 any 타입이 되서 제대로 된 타입 검사가 이뤄지지 않는다는 것입니다.

fetch(/* 어떤 URL */)
  .then((res) => res.json())
  .then((data) => console.log(data.xyz())); // 🫣 타입 에러가 발생하지 않음
//       ^? (parameter) data: any

ts-reset 라이브러리를 사용하면 json() 함수가 any 대신에 unknown 타입을 반환하기 때문에 이러한 위험을 피할 수 있습니다.

자바스크립트에서 원격 API를 호출할 때 사용되는 fetch() 함수에 대해서는 별도 포스팅에서 자세히 다루고 있으니 참고 바랍니다.

ts-reset 설치/설정

자 그럼 이렇게 다양한 타입스크립트의 버그를 고쳐주는 ts-reset은 어떻게 사용할까요?

우선 npm을 이용하여 @total-typescript/ts-reset이라는 패키지를 개발 의존성으로 설치해줍니다.

$ npm i -D @total-typescript/ts-reset

그 다음 프로젝트 최상위 디렉토리에 reset.d.ts 파일을 만들고, 그 안에 @total-typescript/ts-reset 패키지를 불러오면 설정 끝입니다! 정말 간단하죠?

reset.d.ts
import "@total-typescript/ts-reset";

주의 사항

라이브러리 개발을 하시고 계시다면 ts-reset 라이브러리를 사용하지 않도록 주의 바라겠습니다. 왜냐하면 라이브러리 사용자도 ts-reset를 쓰도록 강제하는 효과가 생기기 때문입니다. 따라서 ts-reset 라이브러리는 애플리케이션 개발할 때만 사용하셔야 합니다.

마치면서

굳게 믿었던 타입스크립트에 논리적으로 납득이 어려운 이러한 자잘한 문제들이 여기저기 숨어 있다는 게 재미있지 않나요?

제가 이것을 타입스크립트의 버그라고 표현한 부분에 대해서는 오해가 있을 수 있을 것 같아서 첨언드리고 싶은데요. 사실 제가 본 포스팅에서 다룬 문제 하나 하나를 깊게 파고 들어가시면 타입스크립트가 그렇게 동작하는데는 다 나름의 그럴뜻한 이유가 있다는 것을 알 수 있습니다.

타입스크립트가 현재 v5로 나온지가 벌써 10년이 넘었고 워낙 대중적으로 사용되는 자바스크립트 컴파일러다 보니 이미 이러한 동작에 의존하고 있는 코드가 너무나도 방대하죠. 따라서 과거에 했던 결정이 실수든 아니든 하위 호환성을 보장하려면 쉽게 번복하기가 쉽지가 않다는 부분을 감안할 필요가 있겠습니다.

다행히도 이러한 황당한 문제를 피할 수 있도록 도와주는 ts-reset이라는 고마운 라이브러리가 있으니, 개발자로서 타입스크립트 코딩 경험을 개선하시는데 잘 활용하셨으면 좋겠습니다.