본문 바로가기

javascript/typescript

[타입스크립트] Narrowing

타입스크립트는 자바스크립트의 superset으로, 현재까지는 웹에서 타입스크립트 코드를 바로 사용하는 방법이 존재하지 않는다. ts-node도 컴파일을 거치지는 않더라도 자바스크립트 코드로 변환하여 node에게 전달하고 있다.

 

 이때 타입스크립트의 토대가 되는 자바스크립트는 기본적으로 동적 타입을 채택하고 있으므로, 함수의 매개변수의 타입이 한정되지 않으며, 이로 인해 내부적으로 타입을 검사하는 코드가 존재하지 않는 경우 any로 취급된다. 이에 비해 타입스크립트는 정적 타입 시스템을 채택하고 있기 때문에, 함수의 매개변수를 명확히 한정할 수 있고, 함수 내부에서는 한정된 타입으로 동작하여, 자동완성 기능이 잘 작동하는 편이다.

 

 이렇듯 타입스크립트는 자바스크립트와 채택하는 타입 시스템이 다르므로, if~else, switch, 루프, 참 값 검사 등 타입을 검사하는 영역에서 작동하는 방식이 조금 다르다. 이렇게 타입을 검사한 후 영역 내에서 타입을 한정하는 것을 타입스크립트에서는 Narrowing 이라고 설명한다.

 

 

 

Documentation - Narrowing

Understand how TypeScript uses JavaScript knowledge to reduce the amount of type syntax in your projects.

www.typescriptlang.org

(참고한 공식문서)

typeof : Type Guard

function padLeft(padding: number | string, input: string): string {
    if (typeof padding == 'number') {
        //타입스크립트는 if/else 같은 런타임 제어 흐름 구성에 대한 유형 분석을 수행.
        //위 언급한 유형들에 대해 타입 가드 -> 타입을 축소한다. 많은 에디터에서 우리는 이런 타입들을 볼 수 있다.
        //타입가드의 형태로 인식한다
        return new Array(padding + 1).join(" ") + input;
    }
    return padding + input;
}

기본적으로 typeof는 자바스크립트에도 존재하는 연산자이다. 타입스크립트 공식 문서에서는 이를 특히 type guard라 언급하며, 연산에 대한 결과로 다음 타입들을 기대한다.

  • "string"
  • "number"
  • "bigint"
  • "boolean"
  • "symbol"
  • "undefined"
  • "object"
  • "function"

(null은 object형에 속한다. function도 깊게 들어가면 object에 속하지만, function으로 구분된다)

 

Truthness Narrowing

자바스크립트에는 false를 의미할 수 있는 많은 방법들이 있다.

  • 0
  • NaN
  • "" (empty string)
  • 0n
  • null
  • undefined
function printAll(strs: string | string[] | null) {
    if (strs && typeof strs === 'object') {
        //null 역시 object형에 포함된다. 객체가 아니지만, 하위 호환성을 위해 오류를 수정하지 않고 남겨둔다.
        for (const s of strs) {
            console.log(s);
        }
    } else if (typeof strs === 'string') {
        console.log(strs);
    }
}

위 코드는 if문에서 strs && typeof strs === 'object' 을 통해 string[] 타입을 한정하고 있다.

 

자바스크립트에서는 null, 배열 둘을 typeof 연산자를 통해 구분할 수 없다. typeof 을 이용하면, 두 경우 모두 "object"를 반환하기 때문이다. 따라서, null과 배열을 구분하기 위해서는 다른 방법이 필요한데, 이때 null이 if문 내부에서 false로 취급되는 특징을 이용한다. 이를 통해 null 타입인 상황을 제외하고 string[]으로 한정할 수 있었다.

 

 

strs 조건을 이용해 null을 narrowing 한 상태
null이 포함된 상태

 

Equality Narrowing

자바스크립트에서 타입의 동일성을 다루는 연산자는 4개 존재한다.

  • !==
  • ===
  • !=
  • ==

이때 위 둘은 "완전히" 같은지 여부를 검사하고, 아래 둘은 느슨하게(loosely) 동일성 여부를 검사한다. 이때 이런 연산자들을 이용하여 Narrowing을 진행할 수도 있다.

 

 

function example(x: string | number, y: string | boolean) {
    if (x === y) {
        x.toUpperCase();
        y.toUpperCase();
    } // x, y가 string이라는 사실을 인식한다.
    else {
        //x !== y 인 경우. 둘다 문자열인데 다르거나, 다른 타입이거나
        console.log(x);
        console.log(y);
    }
}

 

위 경우에서는 x가 y와 완전히 일치하는지 비교하고, 일치하면 이를 uppercase로 만든다.

이때 x와 y가 일치하는 경우는 둘의 타입이 string일 때 가능하므로, 내부에서 string으로 타입이 한정된다.

 

interface Container {
    value: number | null | undefined;
}

function multiplyValue(container: Container, factor: number) {
    if (container.value != null) {
        return container.value * factor;
    }
    return container.value * factor;
    // 안된다.
}

 

느슨하게 동등성을 검사하는 !=, == 연산자를 이용할 때 null은 undefined와 구분되지 않는다.

 

위 코드에서 Container 인터페이스는 number | null | undefined 의 유니온 타입을 지닌 value를 가지고, if문 내부에서는 해당 값을 null과 비교한다. 이때 느슨한 검사를 수행하므로, null 및 undefined 둘다 조건에 의해 배제된다.

 

if 조건에 의해 아래 있는 return에서는 value = null | undefined 인 상황만이 남으므로, 아래 코드는 오류임을 알려준다.

타입스크립트가 오류임을 알려주는 모습

 

in operator narrowing

자바스크립트에서는 in 연산자를 통해 해당 프로퍼티가 객체 내부에 있는지 검사할 수 있다. 이에 기반하여 타입스크립트는 narrowing을 수행할 수 있다.

 

type Fish = Animal & { swim: () => void };
type Bird = Animal & { fly: () => void };

function move(animal: Fish | Bird) {
    if ("fly" in animal) {
        //string literal로 정의
        return animal.fly();
    }
    return animal.swim();// 자동으로 다른 조건을 매칭시킨다.
}

위 코드에서 Fish는 swim을, Bird는 fly 를 가진다. if문의 조건인 "fly" in animal 로 인해 animal의 가능한 경우 중 fly가 가능한 Bird 타입이 if문 내부 스코프에 한정된다. 이후 Bird 타입은 나머지 문맥에서 제외되고, 남은 Fish 타입이 이후 문장에서 허용된다.

 

instanceof narrowing

A instanceof B 연산자는 A의 prototype을 조사하고 prototype chaining을 통해 B의 prototype이 연결되는지 여부를 조사한다. 이를 통해 A가 B 의 인스턴스인지 여부를 알려준다.

이를 이용해서도 narrowing이 가능하다.

 

function logValue(x: Date | string) {
    if (x instanceof Date) {
        return x.toISOString();
    }

    return x;
}

 

assignment

어떤 변수를 할당할 때 R-Value를 보고, 적절한 L-Value를 할당한다.

 

   let x = Math.random() < 0.5 ? 10 : "Hello, World!";

    x = 1;
    console.log(x);

    x = "Hello";
    console.log(x);

위 코드에서 x는 random의 반환값에 따라 number 타입의 10  또는 string 타입의 "Hello, World!"가 할당된다.

 

using predicate

위 자바스크립트 구문 기반의 타입 체크를 type predicate을 이용하여 직접적인 방식으로 만들 수 있다.

function isType1(A: type1|type2|...): A is type1 {
	// 검증하는 부분
}

 

A is type1 부분은 type predicate이라고 하며, 이를 반환하는 함수를 정의하여 사용자 정의 type guard 생성이 가능하다.

 

function isFish(pet: Fish | Bird): pet is Fish {
    return (pet as Fish).swim !== undefined;
}

function getSmallPet(): (Fish | Bird) {
    if (Math.random() < 0.5) {
        let animal: Bird = { fly: () => { console.log("I'm Flying!") } };
        return animal;
    }
    else {
        let animal: Fish = { swim: () => { console.log("I'm swimming!") } };
        return animal;
    }
}

{
    let animal = getSmallPet();

    if (isFish(animal)) {
        animal.swim();
    }
    else {
        animal.fly();
    }
}

 

이때 중요한 점이 있다. type predicate는 해당 타입이 맞는지 여부를 알려주지만, 단순히 bool 형으로 알려주는 것이 아니라는 것이다. 만약 isFish의 반환형을 bool로 선언하면 , 다음과 같은 결과가 나온다.

 

Fish와 Bird가 bool형식만으로는 구분되지 않았다.

isFish가 boolean을 반환하게 구성하면 함수는 정말로 boolean 정보만을 가질뿐, 타입 관련된 정보를 가지지 않는다.

따라서 내부 코드가 동일함에도 불구하고 narrowing이 발생하지 않아 타입이 특정되지 않는다.

 

그러므로 사용자 정의 type guard를 생성하기 위해서 is 구문을 반환하게 구성해야 한다.

(C#처럼 if문 내부에서도 is 구문을 지원하면 좀 더 직관적일 것 같다는 생각이 든다. 현재는 instanceof로 유사 동작을 구현할 수 있다.)

 

Discriminated unions

타입스크립트에서는 union을 통해 다수의 타입을 묶을 수 있다. 이때 Union으로 선언된 타입들에 대해 특정 프로퍼티를 공통으로 가지나, 이에 할당된 값은 다를 경우 이를 이용하여 타입을 narrowing할 수 있다.

 

예를 들어, 도형을 구분하는 경우를 생각해보자.

각각의 타입에 kind 프로퍼티를 부여하고, 해당 프로퍼티에 특정 값(가령 "circle")을 부여하자.

 

interface Circle {
    kind: "circle";
    radius: number;
}

interface Square {
    kind: "square",
    sideLength: number;
}

interface Triangle {
    kind: "triangle";
    length: number;
}

이때, 이들의 넓이를 구하는 함수 getArea를 구현한다고 생각해보자. 이를 구현하기 위해서는 위 세개의 클래스를 모두 받을 수 있는 타입이 필요하므로, union을 통해 세가지 클래스를 묶는 Shape 타입을 선언하자.

 

type Shape = Circle | Square | Triangle;

이제, Shape 클래스를 매개변수로 받는 함수 getArea를 정의해보자.

 

function getArea(shape: Shape) {
    switch (shape.kind) {
        case "circle":
            return Math.PI * shape.radius ** 2;
        case "square":
            return shape.sideLength ** 2;
        case "triangle":
            return Math.sqrt(3) / 4.0 * shape.length ** 2; 
        default:
            return shape;
    }
}

위 switch 문에서는 shape의 kind 프로퍼티를 이용하여 이들을 각각의 case 내부에서 유효하도록 narrowing을 진행한다. 이때 shape.kind와 어떤 case도 매치되지 않는 경우, shape는 never 타입을 가지게 된다.

 

타입스크립트의 narrowing은 정의된 모든 타입과 맞지 않는 경우(모든 타입이 위에서 매칭되어 더이상 어떤 타입도 매칭되지 않는 경우) 이를 never 타입으로 규정한다. 이 경우 해당 변수는 함수 내부에서 사용할 방안이 극히 제한된다.

 

결론

 

자바스크립트의 런타임 바인딩 방식에 대한 타입 추론을 위해 타입스크립트는 Type Guard를 이용하여 스코프 내부에서 특정 변수의 타입을 한정하는 Narrowing을 사용한다. if, switch 등의 분기 구문이나 typeof, instanceof, 동등 연산자 등 다양한 요소가 Type Guard로 작용할 수 있으며, 유저는 is 구문을 이용하여 자신만의 Type Guard를 구현할 수 있다.

 

사실 이 문서의 대부분은 몰라도 타입스크립트를 사용하는데 아무 문제가 없기는 하지만, 언어 구현 측면에서 이런 방식을 사용했다는 점을 알고 있는 것도 도움이 될 듯 싶다. 그래도 type predicate 정도는 아는게 좋겠다.