유니온(union, 여러 타입을 세로줄로 묶어 그중 하나를 뜻함)으로 값을 넓게 받아두면 유연하지만, 정작 쓸 때 벽에 부딪힌다. string | number를 받아놓고 .toUpperCase()를 부르면 편집기가 막는다. 숫자일 수도 있는데 문자열 메서드를 부르니 위험하다는 거다. 그래서 이게 지금 이 자리에선 문자열임을 코드로 증명해줘야 한다. 이 증명 과정이 타입 좁히기(narrowing)다. 새 문법이 아니라, 이미 쓰던 자바스크립트 조건문(typeof, in, instanceof 등)을 타입스크립트가 읽어서 그 가지 안의 타입을 줄여주는 것뿐이다. 아래 코드와 에러는 전부 tsc(타입스크립트 컴파일러)로 확인했다.


typeof는 원시 타입을 갈라준다. 문자열, 숫자, 불리언 같은 원시 타입은 typeof로 나눈다. 조건문 안으로 들어가면 타입스크립트가 그 사실을 기억한다.


function pad(value: string | number) {
if (typeof value === "number") {
return " ".repeat(value); // 이 안에서 value: number
}
return value.toUpperCase(); // 여기선 value: string
}


if 블록 안에서는 valuenumber로 좁혀져 repeat에 넘길 수 있고, if를 빠져나온 아래쪽에서는 남는 경우가 문자열뿐이라 자동으로 string이 된다. 후자를 흔히 좁히기의 사족처럼 여기는데, else를 안 써도 위에서 숫자를 걸러냈으니 아래는 문자열이라고 타입스크립트가 스스로 계산한 결과다.


if 하나로 null을 떨궈낸다. string | null처럼 값이 없을 수도 있는 타입은, 그냥 조건으로 참인지만 봐도 좁혀진다.


function greet(name: string | null) {
if (name) {
return "Hi " + name.toUpperCase(); // name: string
}
return "Hi guest";
}


if (name)를 통과했다는 건 namenull도 빈 문자열도 아니라는 뜻이라, 안쪽에서 namestring이 된다. null 검사를 깜빡하고 .toUpperCase()를 바로 부르면 편집기가 "namenull일 수 있다"고 막는데, 이 if 한 줄이면 조용해진다. 실행 중 Cannot read properties of null로 터지던 옛 습관을 컴파일 단계로 당겨오는 셈이다.


in은 객체에 그 키가 있는지로 가른다. 객체 유니온은 typeof로 못 가른다. 둘 다 object라서다. 대신 한쪽에만 있는 속성 이름을 in으로 물어보면 갈린다.


type Dog = { bark: () => void };
type Cat = { meow: () => void };
function speak(pet: Dog | Cat) {
if ("bark" in pet) {
pet.bark(); // pet: Dog
} else {
pet.meow(); // pet: Cat
}
}


"bark" in pet이 참이면 bark를 가진 쪽, 곧 Dog로 좁혀지고 else에서는 Cat이 된다. 서로 다른 모양의 객체를 구분할 때 쓰는데, 뒤에 나올 판별 유니온보다 손이 덜 가는 대신 키 이름을 문자열로 적어야 해서 오타에 약하다.


instanceof는 클래스로 만든 것에 쓴다. Date나 직접 만든 클래스처럼 생성자로 찍어낸 값은 instanceof로 확인한다.


function logYear(x: Date | string) {
if (x instanceof Date) {
return x.getFullYear(); // x: Date
}
return x.length; // x: string
}


x instanceof Date가 참인 가지에서 xDategetFullYear를 부를 수 있고, 아래에서는 string만 남아 .length가 열린다. typeof가 원시 타입 담당이면 instanceof는 클래스 인스턴스 담당이라고 나누면 편하다. 단, 순수 객체 리터럴엔 안 통하니 그건 in이나 다음 방식으로 간다.


판별 유니온은 공통 표식으로 가른다. 여러 모양의 객체를 다룰 때 가장 튼튼한 방법이다. 각 타입에 리터럴 값을 가진 공통 필드(흔히 kindtype)를 두고, 그 값으로 switch를 돌린다.


type Circle = { kind: "circle"; radius: number };
type Rect = { kind: "rect"; width: number; height: number };
type Shape = Circle | Rect;
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.radius ** 2; // s: Circle
case "rect": return s.width * s.height; // s: Rect
}
}


s.kind"circle"case 안에서 sCircle로 좁혀져 radius가 열리고, "rect"에서는 Rect가 되어 widthheight가 열린다. 표식 하나로 갈리니 in처럼 속성 이름을 여기저기 적을 필요가 없다. 이 kind 같은 필드를 판별자(discriminant)라 부르고, 이 구조 전체를 판별 유니온(discriminated union)이라 한다.


never로 빠뜨린 경우를 컴파일에 걸리게 한다. 판별 유니온의 진짜 무기는 여기다. Shape에 삼각형을 추가하고 areacase는 안 늘렸다고 하자.


type Triangle = { kind: "triangle"; base: number; height: number };
type Shape = Circle | Rect | Triangle;
// area의 switch 끝에
default:
const _never: never = s;
return _never;
// error TS2322: Type 'Triangle' is not assignable to type 'never'.


모든 case를 처리했다면 default에 도달한 s는 남는 경우가 없어 never(값이 하나도 없는 타입)여야 한다. 그런데 Triangle을 빠뜨렸으니 sTriangle이 남아, never에 못 넣는다며 에러가 난다. 새 종류를 추가하면 처리 안 한 곳이 컴파일 단계에서 줄줄이 빨개진다. 나는 이 never 한 줄 덕에 도형 타입 하나 추가하고도 밤에 뻗지 않은 적이 여러 번이다.


내 함수로 좁히려면 타입 서술자를 쓴다. 좁히기 로직이 복잡해 함수로 빼고 싶을 때가 있다. 그냥 불리언을 돌려주면 타입스크립트는 그게 좁히기라는 걸 모른다. 반환 타입을 인자 is 타입 꼴로 적어줘야 안다(타입 서술자, type predicate).


type Fish = { swim: () => void };
type Bird = { fly: () => void };
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
if (isFish(pet)) {
pet.swim(); // pet: Fish
} else {
pet.fly(); // pet: Bird
}


반환을 pet is Fish로 적으면, 이 함수가 true를 준 가지에서 타입스크립트는 인자를 Fish로 좁힌다. 그냥 : boolean이라고 적으면 좁혀지지 않고 swim이 막힌다. 다만 이건 내가 서술한 걸 그대로 믿는 방식이라, 안의 판단이 틀리면 잘못된 좁히기가 그대로 통과한다. 그래서 나는 가능한 한 판별 유니온으로 풀고, 그게 안 될 때만 이 서술자를 꺼낸다.


결국 타입 좁히기는 넓게 받은 유니온을 "지금 이 자리에선 이 타입"이라고 좁혀 그 타입의 능력만 열어주는 일이다. 쓰던 조건문 그대로인데 타입스크립트가 옆에서 같이 읽어준다고 보면 된다. 원시 타입은 typeof, 클래스는 instanceof, 순수 객체는 in이나 판별 유니온, 이 갈래만 손에 익으면 유니온이 더는 부담스럽지 않다.