Javascript

타입스크립트(TypeScript) 고수처럼? 복잡한 타입도 '확실하게' 검사하는 비법 대공개!

드리프트2 2025. 3. 22. 16:42

타입스크립트(TypeScript)에서 타입(Type) 검사하기: 복잡한 것도 문제없어요!

이번 글에서는 조금 까다로울 수 있는 타입스크립트(TypeScript)의 '타입(Type)'들이 우리가 생각한 대로 잘 작동하는지 어떻게 똑똑하게 '검사'할 수 있는지 함께 알아볼 건데요.

이걸 제대로 하려면, 코드 실행 단계가 아닌 '타입' 그 자체를 다루는 레벨(Type Level)에서 '이건 이거다!'라고 확실히 말해주는 '단언(Assertion)'과 몇 가지 도구들이 필요합니다.

1. 타입 레벨(Type Level)에서 단언(Asserting)한다는 건 뭘까요?

복잡한 타입을 짜는 건, 마치 우리가 아는 프로그래밍과는 다른 차원에서 코딩하는 것과 비슷한데요.

  • 프로그램 레벨(Program Level): 우리가 보통 코드를 짤 때 쓰는 자바스크립트(JavaScript) 세상입니다. 여기서는 실제 '값(Values)'이나, 어떤 값을 받아서 처리하는 '함수(Functions)' 같은 것들을 다루죠.
  • 타입 레벨(Type Level): 이건 자바스크립트(JavaScript) 세상이 아니라, 타입스크립트(TypeScript)의 특별한 문법으로 이루어진 세상인데요. 여기서는 실제 값이 아닌 '타입(Types)' 그 자체나, 어떤 타입이든 넣어서 쓸 수 있는 '제네릭 타입(Generic Types)' 같은 걸 다룹니다.

자, 우리가 자바스크립트(JavaScript)로 코드를 짤 때는 assert.deepEqual() 같은 함수를 써서, '이거랑 저거랑 진짜 똑같지?' 하고 검사해 볼 수 있잖아요?

const pair = (x) => [x, x];
const result = pair('abc');
assert.deepEqual(
  result, ['abc', 'abc'] // 결과가 ['abc', 'abc']랑 똑같은지 확인!
);

 

그렇다면, 타입 레벨의 코드, 그러니까 '타입' 자체를 가지고 뭔가 복잡하게 만들었을 때, 이게 제대로 됐는지는 어떻게 테스트할 수 있을까요?

복잡한 타입을 다룰 땐 이게 정말 중요한데요.

여기서도 비슷한 '단언(Assertion)'이 필요합니다.

예를 들면 이런 식이죠.

type Pair<T> = [T, T]; // 어떤 타입 T를 받아서 [T, T] 튜플 타입을 만드는 제네릭 타입
type Result = Pair<'abc'>; // 'abc' 타입을 넣어서 Result 타입을 만듦
type _ = Assert<Equal< // Assert와 Equal을 사용해서 검사
  Result, ['abc', 'abc'] // Result 타입이 ['abc', 'abc'] 타입과 같은지 확인!
>>;

 

위에 나온 AssertEqual이라는 제네릭 타입(Generic Types)은 제가 만든 asserttt라는 npm 패키지(npm package)에 들어있는 것들인데요.

이번 글에서는 이 패키지를 두 가지 방식으로 사용해 볼 겁니다.

  • 하나는, 이 패키지가 어떻게 작동하는지 보려고 그 기능들을 우리가 직접 다시 만들어 볼 건데요.
  • 다른 하나는, 우리가 만든 게 진짜 원하는 대로 잘 작동하는지 확인하기 위해 이 패키지의 기능을 실제로 써 볼 겁니다.

혹시 헷갈릴 수 있으니, 우리가 직접 만들어보는 버전에서는 asserttt와는 다른 이름을 사용할게요.

2. 두 타입(Type)이 같은지 어떻게 확인할까요?

타입 레벨에서 단언(Assertion) 기능을 만들 때 가장 중요한 부분은, 두 타입이 '같은지' 확인하는 건데요.

이게 생각보다 꽤 까다롭습니다.

간단하게 생각해 본 방법

가장 쉽게 떠올릴 수 있는 방법은 이렇습니다.

두 타입 X와 Y가 같으려면,

  1. X가 Y에 포함되고 (X extends Y, 즉 X는 Y의 하위 타입)
  2. Y가 X에 포함되면 (Y extends X)

되지 않을까요?

그러면 X랑 Y가 같은 경우밖에 없을 거라고 생각하기 쉬운데요.

(곧 보겠지만, 거의 맞지만 완벽하진 않습니다.)

이 방법을 SimpleEqual1이라는 제네릭 타입(Generic Type)으로 한번 만들어 보면 이렇게 됩니다.

type SimpleEqual1<X, Y> =
  X extends Y // X가 Y에 포함되나?
    ? (Y extends X ? true : false) // 그렇다면, Y도 X에 포함되나? (둘 다 되면 true)
    : false // X가 Y에 포함 안 되면 바로 false
;

// 테스트 해봅시다!
type _ = [
  Assert<Equal<
    SimpleEqual1<'hello', 'hello'>, // (A) 'hello''hello'는 같으니 true?
    true
  >>,
  Assert<Equal<
    SimpleEqual1<'yes', 'no'>, // (B) 'yes''no'는 다르니 false?
    false
  >>,
  Assert<Equal<
    SimpleEqual1<string, 'yes'>, // (C) string 타입과 'yes' 타입은 다르니 false?
    false
  >>,
  Assert<Equal<
    SimpleEqual1<'a', 'a'|'b'>, // (D) 'a''a' 또는 'b' 타입?
    true | false // 어? true도 되고 false도 된다고?
  >>,
];

 

테스트 결과를 보면, (A)와 (B)는 우리가 예상한 대로 잘 나왔습니다.

좀 더 까다로운 (C)의 경우, string 타입은 'yes'라는 구체적인 문자열 리터럴 타입보다 넓은 개념이니 다르다고 나와서 이것도 맞는데요.

아뿔싸, (D)에서는 결과가 true | false, 즉 boolean 타입이 나와 버렸습니다.

왜 그럴까요?

분배(Distribution) 기능 끄기

SimpleEqual이 제대로 작동하게 하려면, 이 분배 기능을 꺼야 하는데요.

조건부 타입에서 extends의 왼쪽에 오는 것이 꾸밈없는 순수한 '타입 변수(Type Variable)'일 때만 분배가 일어납니다(더 자세한 정보).

그래서 분배를 막으려면, extends 양쪽의 타입을 홑원소 튜플(Single-element Tuples, []로 감싼 것)로 만들어주면 됩니다.

type SimpleEqual2<X, Y> =
  [X] extends [Y] // 튜플로 감싸서 분배 방지
    ? ([Y] extends [X] ? true : false)
    : false
;

// 다시 테스트!
type _ = [
  Assert<Equal<
    SimpleEqual2<'hello', 'hello'>,
    true
  >>,
  Assert<Equal<
    SimpleEqual2<'yes', 'no'>,
    false
  >>,
  Assert<Equal<
    SimpleEqual2<string, 'yes'>,
    false
  >>,
  Assert<Equal<
    SimpleEqual2<'a', 'a'|'b'>, // (A) 이제 유니온 타입도 제대로! false
    false
  >>,
  Assert<Equal<
    SimpleEqual2<any, 123>, // (B) any 타입과 123은 같다? true?
    true
  >>,
];

 

이제 (A)처럼 유니온 타입(Union Type)도 제대로 처리하는데요.

하지만, 여전히 문제가 하나 남아있습니다.

(B)를 보면, any 타입은 다른 어떤 타입과 비교해도 같다고 나와 버립니다.

any는 뭐든지 될 수 있는 '만능' 타입이라서 그런데요.

이러면 어떤 타입이 진짜 any인지, 또는 any가 아닌지를 제대로 확인할 수가 없게 됩니다.

any가 아닌 타입을 확인하고 싶어도, 만약 그게 any라면 우리가 비교하는 어떤 타입과도 같다고 나올 테니까요.

any 타입까지 엄격하게 비교하기

any 타입을 다른 타입과 구별하면서 두 타입이 정말 같은지 확인하는 직관적인 방법은 사실 없는데요.

그래서 약간의 '꼼수'를 부려야 합니다.

type StrictEqual<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends // (A)
  (<T>() => T extends Y ? 1 : 2) ? true : false // (B)
;

// 이 꼼수로 다시 테스트!
type _ = [
  Assert<Equal<
    StrictEqual<'hello', 'hello'>,
    true
  >>,
  Assert<Equal<
    StrictEqual<'yes', 'no'>,
    false
  >>,
  Assert<Equal<
    StrictEqual<string, 'yes'>,
    false
  >>,
  Assert<Equal<
    StrictEqual<'a', 'a'|'b'>,
    false
  >>,
  Assert<Equal<
    StrictEqual<any, 123>, // (C) any와 123은 이제 다르다고 나옴! false
    false
  >>,
  Assert<Equal<
    StrictEqual<any, any>, // (D) any와 any는 같다고 나옴! true
    true
  >>,
];

 

이 묘책은 맷 맥커첸(Matt McCutchen)님이 제안한 방법인데요(출처).

그리고 보시다시피 (C)와 (D)에서 우리가 원했던 대로 any를 정확하게 구별해 줍니다!

그런데 이게 어떻게 작동하는 걸까요?(출처)

간단히 말하면, (A)의 함수 타입이 (B)의 함수 타입에 포함되는지 확인하기 위해, 타입스크립트(TypeScript)는 내부적으로 다음과 같은 두 조건부 타입을 비교해야 합니다.

  • T extends X ? 1 : 2
  • T extends Y ? 1 : 2

여기서 T는 아직 정해지지 않은 타입이라서, 두 조건부 타입 모두 당장은 결과를 알 수 없는 '지연된(Deferred)' 상태가 되는데요.

타입스크립트(TypeScript)는 이렇게 지연된 두 조건부 타입이 할당 가능한지(Assignability) 판단할 때, 내부적으로 isTypeIdenticalTo()라는 함수를 사용해서 두 타입이 '구조적으로 완전히 동일한지'를 검사합니다.

이 과정에서 X와 Y가 정확히 같은 타입인지 비교하게 되는 원리입니다.

조금 복잡하게 느껴질 수 있는데요.

3. 어떤 조건이 '참(True)'이라고 어떻게 단언(Assert)할까요?

프로그램/자바스크립트(JavaScript) 레벨에서는, 어떤 조건이 틀렸을 때, 즉 단언(Assertion)이 실패하면, 예외(Exception)를 던져서 프로그램 실행을 멈추고 문제를 알릴 수 있습니다.

function assert(condition) {
  if (condition === false) {
    throw new Error('Assertion failed'); // 조건이 false면 에러 발생!
  }
}
function equal(x, y) {
  return x === y;
}

assert(equal(3, 4)); // 3과 4는 다르므로 에러가 발생합니다.

 

아쉽게도 타입스크립트(TypeScript)에는 코드를 실행하기 전, 즉 컴파일(Compile) 시점에 '이건 틀렸어!'라고 명확하게 실패를 알리는 공식적인 방법이 없습니다.

만약 그런 기능이 있다면, 아마 이런 모습일 텐데요.

// 이런 Fail 타입은 실제로 존재하지 않습니다!
type AssertType1<B extends boolean> = B extends true ? void : Fail;

 

이 타입은 Btruevoid (아무것도 없다는 뜻) 타입이 되고, false면 가상의 Fail 타입을 통해 타입 레벨에서 실패를 알리는 방식입니다.

타입스크립트(TypeScript)에서 Fail과 가장 비슷한 역할을 할 수 있는 것은 never 타입이지만, never는 타입 검사 오류를 직접적으로 일으키지는 않습니다.

하지만 다행히도, 우리가 원하는 것과 꽤 비슷하게 동작하는 괜찮은 방법이 있는데요.

바로 제네릭 타입(Generic Type)의 '제약 조건(Constraint)'을 이용하는 겁니다.

type AssertType2<_B extends true> = void; // (A) _B는 반드시 true여야 함!
type _ = [
  AssertType2<true>, // OK. true를 넣었으니 문제없음.
  // @ts-expect-error: 'false' 타입은 'true' 제약 조건을 만족하지 않습니다.
  AssertType2<false>, // 에러! false는 true가 아니므로 제약 조건 위반.
];

 

제네릭 타입을 사용할 때, 우리가 전달하는 인자(Argument)들은 해당 타입 매개변수(Parameter)의 extends 제약 조건을 반드시 만족해야 합니다.

그렇지 않으면, 타입스크립트(TypeScript)는 타입 에러를 보여주는데요.

바로 이 점을 (A)에서 이용한 겁니다.

AssertType2에 전달하는 값은 반드시 true여야만 하고, 그렇지 않으면 타입 검사기가 불평하는 거죠.

하지만 이 방법에도 한계는 있습니다.

예를 들어 AssertEqual처럼, 두 타입이 같은지를 확인하고 그 결과(boolean)를 바탕으로 단언하는 기능은, 만약 Fail이라는 타입이 있다면 더 깔끔하게 만들 수 있었을 텐데요.

// 만약 Fail이 있다면 이렇게 만들 수 있었을 겁니다.
type AssertEqual<X, Y> = true extends Equal<X, Y> ? void : Fail;

4. 더 많은 도구들과 활용법

참(True)을 거짓(False)으로, 거짓을 참으로: Not 유틸리티 타입

이제 다시 asserttt 패키지의 이름으로 돌아와서, 우리가 두 타입이 같은지 확인하는 Equal과, 어떤 조건이 참인지 단언하는 Assert를 가지고 있다면, 더 많은 도우미 타입들을 만들 수 있는데요.

예를 들어, 불리언(boolean) 값을 반대로 뒤집는 Not<B>가 있습니다.

type Not<B extends boolean> = B extends true ? false : true;

 

Not을 이용하면, 어떤 타입이 다른 타입과 '같지 않음'을 단언할 때 아주 유용합니다.

type _ = Assert<Not<Equal< // Equal<'yes', 'no'>가 false일 것이고, Not으로 감싸면 true가 되므로 Assert 통과!
  'yes', 'no'
>>>;

 

결과가 true 또는 false인 제네릭 타입(Generic Type)을 '서술자(Predicate)'라고 부르는데요.

이런 서술자 타입들은 Assert와 함께 아주 잘 쓰일 수 있습니다.

EqualNot이 바로 그런 서술자인데요.

하지만 더 많은 서술자를 생각해 볼 수 있고, 실제로 유용하게 쓰입니다.

예를 들면 이런 것들이죠.

/**
 * `Source` 타입이 `Target` 타입에 할당 가능한가?
 * (Source 타입의 값이 Target 타입 변수에 들어갈 수 있는가?)
 */
type Assignable<Target, Source> = Source extends Target ? true : false;

type _ = [
  Assert<Assignable<number, 123>>, // 123(숫자 리터럴 타입)은 number 타입에 할당 가능
  Assert<Assignable<123, 123>>, // 123은 123에 할당 가능

  Assert<Not<Assignable<123, number>>>, // number 타입은 123 타입에 할당 불가능 (더 넓은 범위)
  Assert<Not<Assignable<number, 'abc'>>>, // 'abc'(문자열)는 number 타입에 할당 불가능
];

 

asserttt 패키지에는 이 외에도 더 많은 서술자들이 정의되어 있습니다.

오류(Error)가 나는지 단언하기

때로는 우리가 예상한 곳에서 '오류'가 발생하는지 테스트해야 할 때도 있는데요.

자바스크립트(JavaScript) 레벨에서는, assert.throws() 같은 함수를 사용해서 특정 코드가 실행될 때 예상한 오류를 던지는지 확인할 수 있습니다.

assert.throws(
  () => null.prop, // null에서 .prop를 읽으려고 하면 에러 발생!
  {
    name: 'TypeError',
    message: "Cannot read properties of null (reading 'prop')",
  }
);

 

타입 레벨에서는, @ts-expect-error라는 특별한 주석을 사용할 수 있습니다.

이 주석 바로 다음 줄에 오는 코드에서 타입 오류가 발생할 것으로 예상한다는 뜻인데요.

// @ts-expect-error: 'null' 값은 여기서 사용할 수 없습니다.
null.prop; // 타입스크립트는 이 코드에서 오류가 날 것을 알고 있음.

 

기본적으로 @ts-expect-error는 그냥 오류가 있다는 것만 확인하고, 정확히 어떤 오류인지까지는 확인하지 않는데요.

어떤 오류인지까지 꼼꼼하게 확인하고 싶다면, ts-expect-error 같은 npm 패키지(npm package) 도구를 사용할 수 있습니다.

조금 더 복잡한 예시

재미 삼아, 자바스크립트(JavaScript) 레벨의 테스트와 그에 상응하는 타입 레벨 테스트를 하나 더 비교해 볼까요?

이건 자바스크립트(JavaScript) 코드입니다.

function upperCase(str) {
  if (typeof str !== 'string') { // 문자열이 아니면 에러!
    throw new TypeError('Not a string: ' + str);
  }
  return str.toUpperCase();
}
assert.throws( // upperCase(123)을 호출하면 TypeError가 발생하는지 확인
  () => upperCase(123),
  {
    name: 'TypeError',
    message: 'Not a string: 123',
  }
);

 

타입 레벨 코드에서는, 런타임(실행 시점)에 타입을 검사하는 부분은 생략하겠습니다.

(물론 실제 코드에서는 여전히 의미 있을 때가 많습니다.)

function upperCase(str: string) { // 파라미터 str은 string 타입이어야 함
  return str.toUpperCase();
}

// @ts-expect-error: 'number' 타입의 인수는 'string' 타입의 매개변수에 할당될 수 없습니다.
upperCase(123); // 숫자를 넣었으니 타입 오류 발생!

 

어떤 값(Value)의 타입(Type)을 단언하기

타입을 테스트할 때, 우리가 만든 어떤 '값(Value)'이 특정 타입을 가지고 있는지, 예를 들어 타입 추론(Inference)이나 제네릭 타입(Generic Type)을 통해 얻어진 타입과 같은지, 확인하고 싶을 때도 있는데요.

한 가지 방법은 typeof 연산자를 사용하는 겁니다(A).

const pair = <T>(x: T): [T, T] => [x, x];
const value = pair('a' as const); // 'a' 리터럴 타입을 유지
type _ = Assert<Equal<
  typeof value, ['a', 'a'] // (A) value의 타입이 ['a', 'a']와 같은지 확인
>>;

 

다른 방법은 assertType() 같은 도우미 함수를 이용하는 건데요.

assertType<['a', 'a']>(value);

 

프로그램 레벨(값)의 함수를 쓰면, 값을 typeof를 써서 타입 레벨로 변환할 필요 없이 바로 인자로 넘길 수 있어서 코드가 좀 더 간결해집니다.

assertType() 함수는 대략 이렇게 생겼습니다.

function assertType<T>(_value: T): void { }

 

우리는 매개변수 _value로 실제로 아무것도 하지 않는데요.

단지 이 값이 타입 매개변수 T에 할당 가능한지(Assignable)를 정적으로(Static, 컴파일 시점에) 확인하는 용도로만 씁니다.

assertType()의 한계는 '할당 가능성'만 확인한다는 점입니다.

타입이 정확히 '같은지(Equality)'는 확인하지 못하죠.

예를 들어, 어떤 값이 정확히 string 타입인지, 더 구체적인 타입이 아니라 string 그 자체인지를 확인할 수는 없습니다.

const value_string: string = 'abc';
const value_abc: 'abc' = 'abc';

assertType<string>(value_string); // OK
assertType<string>(value_abc); // OK. 'abc' 타입은 string에 할당 가능

 

(A)에서 value_abc의 타입('abc')은 string에 할당 가능하지만, string 타입과 '같지는' 않습니다.

반면에, 우리가 앞에서 본 Equal을 사용하면, 어떤 값의 타입이 string보다 더 구체적인 타입이 '아니라' 정확히 string인지 등을 확인할 수 있습니다.

type _ = [
  Assert<Equal<
    typeof value_string, string // value_string의 타입은 string과 같음
  >>,
  Assert<Not<Equal< // value_abc의 타입('abc')은 string과 같지 않음
    typeof value_abc, string
  >>>,
];

 

일반 코드에서 타입 레벨 단언(Assertion)이 유용한 경우

가끔은 타입 레벨 단언(Assertion)이 테스트 코드가 아닌, 우리가 작성하는 보통의 코드에서도 유용할 때가 있는데요.

예를 들어, Person이라는 타입과, 그 타입의 모든 키(Key)들을 담고 있는 personKeys라는 배열이 있다고 해봅시다.

interface Person {
  first: string;
  last: string;
}

const personKeys = [
  'first',
  'last',
] as const; // 'as const'로 각 요소가 리터럴 타입이 되도록 함

 

만약 우리가 personKeys 배열을 실수로 잘못 작성했을 때, 예를 들어 키 이름을 잘못 쓰거나 빼먹었을 때, 컴파일러가 우리에게 경고해주기를 바란다면, 타입 레벨 단언을 사용할 수 있습니다.

type _1 = Assert<Equal< // Person의 모든 키(keyof Person)와 personKeys 배열 요소들의 타입이 같은지 확인
  keyof Person,
  (typeof personKeys)[number] // (A) personKeys 배열의 모든 요소 타입을 유니온으로 만듦
>>;

 

(A)에서는, 인덱싱된 접근 타입(Indexed Access Type, T[K])을 사용해서 personKeys 배열(정확히는 그것의 typeof 결과)의 모든 요소들의 타입을 유니온(Union)으로 만들었습니다.

(이 기법에 대한 더 자세한 정보)

type _2 = [
  Assert<Equal<
    (typeof personKeys)[number], // 'first' | 'last' 타입
    'first' | 'last'
  >>,
  Assert<Equal<
    keyof Person, // 'first' | 'last' 타입
    'first' | 'last'
  >>,
];

 

만약 Person 인터페이스에 age: number를 추가하거나, personKeys에서 'last'를 빼먹으면, _1Assert<Equal<...>> 부분에서 타입 에러가 발생해서 우리가 실수를 알아차릴 수 있게 됩니다.

타입 레벨(Type-Level) 테스트 실행하기

타입스크립트(TypeScript)로 작성된 보통의 테스트 코드를 실행하려면, 먼저 자바스크립트(JavaScript)로 변환(Transpile)한 다음, 그 자바스크립트(JavaScript) 코드를 실행하는데요.

만약 테스트 코드에 우리가 살펴본 '타입 레벨 단언(Type-Level Assertion)'들이 포함되어 있다면, 단순히 코드를 실행하는 것만으로는 부족하고, 추가적으로 이 코드들의 '타입 검사'를 해주는 과정이 필요합니다.

두 가지 방법이 있습니다.

  1. 먼저 자바스크립트(JavaScript) 테스트를 실행합니다. 그 다음에, 타입스크립트 컴파일러(tsc)를 --noEmit 옵션 (실제 자바스크립트 파일은 생성하지 않음) 등과 함께 실행해서 테스트 파일들의 타입 오류가 없는지 검사하는 방법입니다.
  2. tsxts-node 같은 도구를 사용하는 방법인데요. 이 도구들은 타입스크립트(TypeScript) 코드를 실행하기 전에 내부적으로 타입 검사를 해주거나, 즉석에서 자바스크립트(JavaScript)로 변환하여 실행해 줍니다.