Javascript

타입스크립트의 깜짝 비밀: 조건문은 어떻게 타입을 예측할까요?

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

안녕하세요!

오늘은 코딩할 때 실수를 줄여주는 고마운 친구, 타입스크립트(TypeScript)에 대한 재미있는 이야기를 해볼까 하는데요.

특히 타입스크립트(TypeScript)가 코드 속 데이터의 종류(타입)를 확인하는 방법 중 하나인 '조건부 타입'이라는 기능에 숨겨진 비밀을 함께 알아볼까 합니다.

타입스크립트(TypeScript) 설명서에는 이런 말이 있습니다.

"조건부 타입으로 뭔가를 확인하면, 우리는 새로운 정보를 얻을 수 있어요.

마치 탐정이 단서를 찾아 범위를 좁혀가듯, 조건부 타입의 '참(true)' 결과는 우리가 다루는 데이터(제네릭)의 종류를 더 정확하게 알려줍니다."

이게 무슨 말일까요?

생각보다 더 신기한 방식으로 작동하는데요, 한번 쉽게 풀어볼까요?

1. 데이터 종류(타입) 알아맞히기 기초: '범위 좁히기'

컴퓨터 프로그램에서는 숫자, 글자, 목록 등 다양한 종류의 데이터를 다루는데요.

이걸 '타입'이라고 부릅니다.

그런데 가끔은 T처럼, 이 변수에 정확히 어떤 종류의 데이터가 들어올지 모를 때가 있습니다.

마치 내용물을 모르는 선물 상자 같달까요?

이런 '정체불명'의 데이터 T에는 아무 연산이나 막 할 수는 없습니다.

예를 들어, 상자 안에 뭐가 들었는지 모르는데 무작정 "길이(length)가 얼마야?"라고 물어볼 순 없겠죠?

글자나 목록은 길이를 알 수 있지만, 숫자는 길이가 없으니까요.

그래서 아래 코드는 타입스크립트(TypeScript)가 "이건 위험해!"하고 알려줍니다.

// 이런! 'T' 상자에 뭐가 들었는지 모르는데 'length'를 물어보면 안 돼!
// type GetLength1<T> = T['length']; // 오류 발생!

 

이때 '조건부 타입'이 등장합니다!

마치 if/else 문처럼, 데이터의 종류를 확인하고 경우에 따라 다른 행동을 하게 해줍니다.

"만약 T 상자 안에 length를 가질 수 있는 것(글자나 목록 같은 거)이 들어있다면, 그 길이를 알려줘.

아니라면, '알 수 없음(never)'이라고 표시해줘!"

이런 식으로 말입니다.

// T가 만약 length 속성을 가진 종류라면(?), T의 length를 알려주고, 아니면 never!
type GetLength2<T> = T extends {length: unknown} ? T['length'] : never;

// 예시를 볼까요?
// GetLength2<['x']> // ['x']는 목록(길이 1) -> 결과: 1
// GetLength2<123>    // 123은 숫자(길이 없음) -> 결과: never (알 수 없음)
// GetLength2<123 | ['x'] | []> // 숫자 또는 ['x'] 또는 빈 목록[] -> 결과: 1 | 0
// (A) 마지막 줄은 왜 1 또는 0일까요?

 

(A) 부분을 보면, 123 때문에 나온 never는 최종 결과에서 사라졌습니다.

never는 '불가능' 혹은 '해당 없음' 같은 의미라서, 다른 가능한 결과와 합쳐질 때는 그냥 무시된답니다.

마치 0에 어떤 숫자를 더해도 그 숫자가 그대로인 것처럼요!

2. '범위 좁히기'를 넘어서는 신기한 현상

여기까지는 "아하, 조건을 걸어서 데이터 종류를 더 정확히 아는 거구나!"

싶으실 텐데요.

이제부터 조금 더 신기한 부분을 살펴보겠습니다.

진짜 놀라운 건 맨 마지막에 나옵니다!

두 데이터 종류 XY가 서로 같은지 비교하는 간단한 방법을 만들어 볼까요?

SimpleEqual1이라는 이름표를 붙여주겠습니다.

// X가 Y의 한 종류이고, 동시에 Y도 X의 한 종류이면 true, 아니면 false
type SimpleEqual1<X, Y> =
  X extends Y // X가 Y에 포함되는가?
    ? (Y extends X ? true : false) // 그렇다면, Y도 X에 포함되는가?
    : false // X가 Y에 포함 안되면 무조건 false
;

// 예시
// SimpleEqual1<'a', 'a'|'b'|'c'>
// 'a''a' 또는 'b' 또는 'c' 중 하나인가? (네)
// 그럼 'a'|'b'|'c''a' 인가? (아니오, 'b''c' 때문에)
// 어? 그런데 결과가 왜 true | false (참 또는 거짓) 둘 다 나오죠? (A)

 

(A)에서 왜 결과가 딱 떨어지게 truefalse가 아니라, true | false 둘 다 나올까요?

이건 조건부 타입이 'OR'로 연결된 여러 가능성('a'|'b'|'c' 같은 것)을 만났을 때, 각각의 가능성에 대해 따로따로 확인하기 때문입니다.

이걸 어려운 말로 분배 법칙(distributive)이라고 하는데요.

마치 "사과 또는 바나나가 과일인가?"라고 물으면, "사과가 과일인가?

(네!)", "바나나가 과일인가?

(네!)" 이렇게 각각 따져보는 것과 비슷합니다.

위 예시(SimpleEqual1<'a', 'a'|'b'|'c'>)를 타입스크립트(TypeScript)가 처리하는 순서를 상상해보면 이렇습니다.

  1. 첫 번째 질문: X ('a')가 Y ('a'|'b'|'c')에 포함되나요?
    • 네, 포함됩니다. 그럼 다음 질문으로 넘어갑니다.
  2. 두 번째 질문: Y ('a'|'b'|'c')가 X ('a')에 포함되나요?
    • 여기서 Y'a' | 'b' | 'c' 세 가지 가능성을 가지므로, 각각 따져봅니다.
      • 'a'X ('a')에 포함되나요? (네) -> 결과 후보: true
      • 'b'X ('a')에 포함되나요? (아니오) -> 결과 후보: false
      • 'c'X ('a')에 포함되나요? (아니오) -> 결과 후보: false
    • 모든 결과 후보(true, false)를 합치니 최종 결과는 true | false가 되는 것입니다.

3. 진짜 깜짝 비밀: never의 등장!

여기까지도 "음, 좀 복잡하지만 이해는 되네" 싶을 수 있습니다.

그런데 진짜 이상한(?) 현상은 지금부터입니다.

아까 그 비교 과정을 조금 더 자세히 들여다보는 SimpleEqual2를 만들어 볼까요?

type SimpleEqual2<X, Y> =
  X extends Y
    ? (Y extends X ? ['trueY', X, Y] : ['falseY', X, Y]) // 첫 질문 통과 시
    : ['falseX', X, Y] // 첫 질문 실패 시
;

// SimpleEqual2<'a'|'b', 'a'|'b'> 를 실행하면?
// 결과:
// | ['trueY', 'a', 'a'] // 'a''a' 비교 -> true
// | ['falseY', never, 'b'] // 어? 'a''b' 비교인데 왜 never가 나왔지?
// | ['falseY', never, 'a'] // 어? 'b''a' 비교인데 왜 never가 나왔지?
// | ['trueY', 'b', 'b'] // 'b''b' 비교 -> true

 

결과 중간에 갑자기 never (불가능/해당 없음)가 등장했습니다!

왜일까요?

추측 1: 첫 번째 질문(X extends Y)을 통과하면서 X의 정보가 좀 더 확실해져서 그런 걸까요?

예를 들어 X'a'|'b'라는 정보가 생겨서?

하지만 그 정보만으로는 never가 나올 이유가 없습니다.

'a''b''a'|'b'에 포함되니까요.

추측 2 (이게 정답에 가까움!): 타입스크립트(TypeScript)는 첫 번째 질문(X extends Y)을 통과할 때 사용된 조건 자체를 기억하고 있다는 것입니다!

그리고 두 번째 질문(Y extends X)을 할 때, 이 기억된 첫 번째 조건을 만족하지 못하는 경우가 생기면 never를 표시하는 것 같습니다.

좀 더 쉽게 말하면, SimpleEqual2<'a'|'b', 'a'|'b'> 예시에서…

  • X'a'일 때: 첫 질문 'a' extends 'a'|'b'는 통과합니다. 이때 "X는 'a'였다"는 정보 + "X는 'a'|'b'에 포함되어야 한다"는 규칙이 기억됩니다.
    • 두 번째 질문 'a'|'b' extends 'a'를 할 때:
      • 'a' extends 'a'는 통과 -> ['trueY', 'a', 'a']
      • 'b' extends 'a'는 실패합니다. 그런데 이때, 첫 질문에서 X는 'a'였는데, 지금 비교하는 Y 값 'b'는 그 'a'가 아니네? 이런 식으로 뭔가 어긋나서 ['falseY', never, 'b'] 처럼 never가 들어가는 것으로 보입니다. (정확한 내부 동작은 더 복잡하지만, 이런 느낌입니다!)
  • X'b'일 때도 비슷하게 처리되어 ['falseY', never, 'a']['trueY', 'b', 'b']가 나옵니다.

신기하죠?

타입스크립트(TypeScript)의 조건부 타입은 단순히 데이터 종류를 확인하는 것을 넘어, 이전에 확인했던 조건(규칙)까지 기억하면서 더 똑똑하게 (하지만 때로는 예상치 못하게) 작동한다는 것입니다.

마무리하며

오늘은 타입스크립트(TypeScript)의 조건부 타입이 단순히 타입을 좁히는 것 이상으로, 이전에 확인된 제약 조건을 기억하며 작동하는 신기한 방식에 대해 알아봤습니다.

코딩을 하다 보면 이런 예상치 못한 동작 때문에 당황할 때도 있지만, 원리를 알고 나면 타입스크립트(TypeScript)를 더 잘 활용하는 데 도움이 될 것입니다.