
타입스크립트(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'] 타입과 같은지 확인!
>>;
위에 나온 Assert
와 Equal
이라는 제네릭 타입(Generic Types)은 제가 만든 asserttt
라는 npm 패키지(npm package)에 들어있는 것들인데요.
이번 글에서는 이 패키지를 두 가지 방식으로 사용해 볼 겁니다.
- 하나는, 이 패키지가 어떻게 작동하는지 보려고 그 기능들을 우리가 직접 다시 만들어 볼 건데요.
- 다른 하나는, 우리가 만든 게 진짜 원하는 대로 잘 작동하는지 확인하기 위해 이 패키지의 기능을 실제로 써 볼 겁니다.
혹시 헷갈릴 수 있으니, 우리가 직접 만들어보는 버전에서는 asserttt
와는 다른 이름을 사용할게요.
2. 두 타입(Type)이 같은지 어떻게 확인할까요?
타입 레벨에서 단언(Assertion) 기능을 만들 때 가장 중요한 부분은, 두 타입이 '같은지' 확인하는 건데요.
이게 생각보다 꽤 까다롭습니다.
간단하게 생각해 본 방법
가장 쉽게 떠올릴 수 있는 방법은 이렇습니다.
두 타입 X와 Y가 같으려면,
- X가 Y에 포함되고 (X
extends
Y, 즉 X는 Y의 하위 타입) - 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;
이 타입은 B
가 true
면 void
(아무것도 없다는 뜻) 타입이 되고, 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
와 함께 아주 잘 쓰일 수 있습니다.
Equal
과 Not
이 바로 그런 서술자인데요.
하지만 더 많은 서술자를 생각해 볼 수 있고, 실제로 유용하게 쓰입니다.
예를 들면 이런 것들이죠.
/**
* `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'를 빼먹으면, _1
의 Assert<Equal<...>>
부분에서 타입 에러가 발생해서 우리가 실수를 알아차릴 수 있게 됩니다.
타입 레벨(Type-Level) 테스트 실행하기
타입스크립트(TypeScript)로 작성된 보통의 테스트 코드를 실행하려면, 먼저 자바스크립트(JavaScript)로 변환(Transpile)한 다음, 그 자바스크립트(JavaScript) 코드를 실행하는데요.
만약 테스트 코드에 우리가 살펴본 '타입 레벨 단언(Type-Level Assertion)'들이 포함되어 있다면, 단순히 코드를 실행하는 것만으로는 부족하고, 추가적으로 이 코드들의 '타입 검사'를 해주는 과정이 필요합니다.
두 가지 방법이 있습니다.
- 먼저 자바스크립트(JavaScript) 테스트를 실행합니다. 그 다음에, 타입스크립트 컴파일러(
tsc
)를--noEmit
옵션 (실제 자바스크립트 파일은 생성하지 않음) 등과 함께 실행해서 테스트 파일들의 타입 오류가 없는지 검사하는 방법입니다. tsx
나ts-node
같은 도구를 사용하는 방법인데요. 이 도구들은 타입스크립트(TypeScript) 코드를 실행하기 전에 내부적으로 타입 검사를 해주거나, 즉석에서 자바스크립트(JavaScript)로 변환하여 실행해 줍니다.
'Javascript' 카테고리의 다른 글
타입스크립트(TypeScript) 왜 써야 할까? (0) | 2025.03.22 |
---|---|
타입스크립트(TypeScript)가 뭔가요? 자바스크립트(JavaScript) 개발자를 위한 간단 소개 (0) | 2025.03.22 |
타입스크립트의 깜짝 비밀: 조건문은 어떻게 타입을 예측할까요? (0) | 2025.03.22 |
Framer Motion 사용법: 초보자 가이드 (0) | 2025.03.19 |
2025년에 Node.js에서 .env 파일 읽는 방법 (최신 정보) (0) | 2025.03.19 |