프로필 사진
Sojin Park
Frontend Dev

바닥 타입
TypeScript에서 never로 표현되는 바닥 타입이 무엇인지와 그 성질
2018. 12. 30.

타입 이론에서 바닥 타입Bottom type은 속해 있는 값이 하나도 없는 타입이다. 기호 상으로는 \perp으로 나타낸다.

TypeScript에서는 never, Haskell에서는 Void, Scala에서는 Nothing으로 구현되어 있다. C++에서의 void와는 다르므로 주의할 것. (C++에서의 void는 단일 타입 () 또는 Unit 으로, JavaScript의 undefined에 대응된다.)

바닥 타입을 집합으로 생각한다면 공집합 ={}\emptyset = {}에 대응된다. 즉, 타입은 존재하나, 속하는 값은 없다.

성질

모든 타입의 하위 타입

바닥 타입 \perp는 모든 타입의 하위 타입이 된다. 즉 바닥 타입은 숫자 타입 (Int, Float ...), 문자열 타입 String 등 어떤 타입에도 속한다. 직관적으로 이해하기 위해서 공집합을 생각해 보자. 바닥 타입에는 속하는 원소가 존재하지 않기 때문에 집합으로 생각하면 공집합으로 볼 수 있다. 공집합이 모든 집합의 부분집합임은 잘 알려져 있는 성질이다. 이와 마찬가지로 바닥 타입은 모든 타입의 하위 타입이 된다.

타입은 존재하지만 값은 없다

바닥 타입에는 속하는 값이 없기 때문에 일반적인 방법으로 변수를 선언할 수 없다. 예를 들어 TypeScript에서 아래와 같이 never 타입의 변수를 만들 수 있는데,

let foo: never = (() => {
  throw new Error('오류 발생!');
})();

foo 등의 변수가 never 타입이라는 것은 이 변수에 값이 할당되기 전에 함수 또는 프로그램이 종료되거나 어떤 부수 작용(side-effect)이 발생하기 때문에 실제로는 값이 주어질 수 없음을 의미한다. 즉 바닥 타입은 일반적이지 않은 프로그램 흐름을 나타낸다. VS Code와 같은 IDE에서 never 타입인 foo에 대해서는 어떠한 IntelliSense 정보도 주어지지 않는다.

어떤 상황에서 발생하나?

바닥 타입 \perp가 발생하는 경우를 설명하기 위해 TypeScript를 사용한다.

영원히 끝나지 않는 함수의 반환값

다음과 같이 무한 루프를 돌고 있는 함수의 경우 값을 반환하지 않는다. 이 경우, 함수의 반환 타입은 never = \perp가 된다.

// 이 함수의 반환 타입은 never이다.
function neverEndingFunc() {
  while (true) {
    console.log('이 함수는 절대 끝나지 않는다');
  }
}

함수가 값을 반환하지 않고 예외를 던질 때

함수가 return 문을 통해 값을 반환하는 것이 아니라 throw 문을 통해 예외를 던지는 함수일 경우 반환 타입이 never로 추론된다.

// 이 함수의 반환 타입은 never이다.
function someFunc() {
  throw new Error('Not implemented');
}

위와 같이 함수가 “미구현 에러”를 던지도록 하는 것은 프로그래밍을 할 때 자주 만나게 되는 패턴이다. 인터페이스나 클래스를 설계할 때 멤버 함수의 타입을 정의해둔 뒤 아직 개발이 완료되지 않은 경우 예외를 던지도록 할 수 있다.

interface SomeInterface {
  someMethod: (arg: number) => number;
}

const implementation: SomeInterface = {
  // 이 함수의 타입은 (arg: number) => never
  // never는 어떤 타입에도 속하는 타입이기 때문에,
  // number에도 속하고, 따라서 컴파일 에러가 발생하지 않음
  someMethod: function(arg: number) {
    throw new Error('Not implemented');
  },
};

위에서 다루었듯 바닥 타입 \perp (= never)가 모든 타입의 하위 타입이기 때문에 위와 같이 코드를 작성해도 컴파일 에러는 발생하지 않는다.

타입 추론이 강력한 언어에서 불가능한 분기에 진입했을 때

예를 들어 TypeScript에서 아래와 같은 코드를 생각할 수 있다.

function doSomethingWith(arg: string | number) {
  if (typeof arg === 'string') {
    // arg가 string이면, 어떤 동작을 취한다.
  } else if (typeof arg === 'number') {
    // arg가 number이면, 어떤 동작을 취한다.
  } else {
    // 주어진 arg의 타입 정보를 보면, 이곳은 불가능한 분기이다.
    // 따라서 arg는 여기에서 never 타입으로 추론된다.
  }
}

위에서 arg는 문자열 혹은 숫자로 선언되었는데 else 문에서는 arg가 문자열도 숫자가 아닌 상황을 가정하고 있다. 이럴 때 arg의 타입은 never가 된다.

빈 배열을 만들었을 때의 타입 추론

// list의 타입은 never[]로 추론된다
const list = [];

TypeScript에서 빈 배열을 만들면 그 타입은 never[]로 추론된다. 배열 안에 실제로 원소가 없기 때문에 그렇기도 하지만 핵심적으로 never가 모든 타입의 하위 타입이라는 점과 연관된다. 위에서 () => number() => never가 할당될 수 있듯 어떤 타입의 배열에도 never[]를 할당할 수 있다.

// list의 타입은 never[]이다
const list = [];

const numberList: number[] = list;
const numberList2: number[] = [];

const stringList: string[] = list;
const stringList2: string[] = [];

TypeScript에서 명령형 프로그래밍 방법론으로 빈 배열을 먼저 선언한 뒤 push 등을 이용하여 값을 채우려면 빈 배열에 명시적으로 타입을 붙여 주어야 한다.

빈 리스트의 타입이 바닥 타입 \perp의 리스트로 선언되는 것은 다른 언어에서도 공통된다. 예를 들어 Scala에서 빈 리스트를 나타내는 Nil의 타입은 List[Nothing]이다.

바닥 타입을 인자로 받는 함수 absurd

Haskell에서는 absurd라고 하는 함수가 있는데 TypeScript로 옮기자면 다음과 같다.

let absurd: <T>(arg: never) => T;

이 함수는 실제로 구현할 수 없는 함수이지만 함수의 요건 자체는 충족한다. 왜냐하면 함수의 정의에서

정의역 XX과 공역 YY가 있어서 모든 xXx \in X에 대해 yYy \in Y가 유일하게 정해진 대응 f:XYf: X \rightarrow Y을 함수라고 한다.

정의역의 모든 원소 xx \in \perp에 대해 대응되는 값을 정하면 되는데 정의역이 공집합이므로 대응시킬 값을 정할 필요도 없이 그것으로 함수의 요건이 충족되기 때문이다.