타입스크립트 공식 문서 Narrowing 정리하기

미소·2023년 5월 7일
0
post-thumbnail
post-custom-banner

🔗 https://www.typescriptlang.org/docs/handbook/2/narrowing.html

TL;DR

  • 타입 축소 / 타입 가드
    • typeof
    • in
    • instanceof
    • 값의 참 / 거짓을 이용하여 타입 줄이기 (truthy / falsy)
    • 비교를 통한 타입 줄이기
  • 제어 흐름 분석

타입 축소란?

위의 코드에서 padding은 number 타입이거나 string 타입을 가집니다.

하지만 repeat 메서드는 number 타입만 가능하기에 number | string 타입은 할당될 수 없다는 에러가 발생합니다.

이렇게 변경하면 어떨까요?

function padLeft(padding: number | string, input: string) {
  if (typeof padding === "number") {
    return " ".repeat(padding) + input;
  }
  return padding + input;
}

타입스크립트는 정적 유형 검사를 통해 코드를 실행하지 않더라도 if/else, 삼항 연산자, 루프, truthy 값 체크 등과 같이 런타임에 일어날 것들을 미리 파악할 수 있습니다.

위 코드에서 if문 안에 있는 체크 방법을 Type Guard(타입 가드) 라고 합니다.

타입스크립트는 주어진 위치에서 가능한 가장 구체적인 타입을 찾으려 합니다.

타입 가드와 할당 구문을 살펴보고 타입을 선언된 것보다 더 구체적인 타입으로 정제하는 과정을 Narrowing (타입 축소) 라고 부릅니다.

타입 가드

컴파일러가 타입을 예측할 수 있도록 타입의 범위를 축소시켜 주는 것을 의미합니다.

타입 축소

말 그대로 타입의 범위를 축소하는 것을 의미합니다.
즉, 타입 가드를 해주기 위해 타입 축소를 하는 거라고 말할 수 있습니다.


VSCode에서도 이 축소 과정을 관찰할 수 있습니다.

3번째 줄에선 number 타입이지만, 마지막 줄에선 string 타입입니다.

typeof 연산자를 통한 타입 가드

자바스크립트/타입스크립트에서 값의 타입에 대한 기본적인 정보를 제공하는 typeof 연산자를 지원합니다.
typeof 는 아래 값들을 반환합니다.

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

padLeft 예제에서 본 것처럼 이 연산자는 JavaScript 라이브러리에서 자주 등장하며 TypeScript는 이를 이용하여 다양한 분기의 타입을 좁힐 수 있습니다.

TypeScript에서 typeof 를 통해 타입을 체킹하는 과정은 타입 가드하는 방법 중 하나입니다.
typeof 를 이용하면 JavaScript로 사용했을 때의 유별난 오류를 잡을 수 있습니다.

대표적인 예시는 null 입니다.

console.log(typeof null);        // ??
console.log(typeof undefined);   // ??
  • 각각 어떤 값이 반환될까요?
    console.log(typeof null);       // object
    console.log(typeof undefined);  // undefined
    null은 undefined와 달리 object를 반환합니다.

    ⚠️ typeof null 은 “object”일까요?
    결론을 먼저 말씀드리자면 ECMAScript의 첫 번째 릴리스 이후의 버그 입니다.
    typeof null을 “null”로 반환하도록 시도가 있었지만 많은 기존 사이트들을 손상시켰기에 실현되지 않았습니다.

    ⚠️ 이해가 안 가는데요…?
    조금 더 자세히 설명해보자면 자바스크립트의 타입과 값은 32비트로 표현이 되는데,
    null 같은 경우 아무것도 없다는 뜻으로 32비트 전부 0으로 표기됩니다. (널 포인터)
    하지만 32비트 중 앞 3비트는 타입을 나타내는 값으로 000이라면 object라는 초기 설정이 있었기에 모든 값이 0인 null은 타입이 object가 된 것입니다.
    왜 그랬는지 더 자세히 알고싶으시다면 https://2ality.com/2013/10/typeof-null.html 참고해보세요!

진실성 축소 (Truthiness narrowing)

부제: 값의 참 / 거짓을 이용하여 타입 줄이기 (truthy / falsy)


JavaScript에서 조건문으로 사용할 수 있는 것에는 &&||if! 등이 있습니다.

보통 조건문의 condition 항목에는 true 또는 false 값을 갖는 boolean이 들어가게 되는데, 만약 boolean이 아니라면 해당 값을 boolean으로 형변환을 시키게 됩니다.

function getUsersOnlineMessage(numUsersOnline: number) {
  if (numUsersOnline) {
    return `There are ${numUsersOnline} online now!`;
  }
  return "Nobody's here. :(";
}

아래 값들은 falsy한 값들입니다.

  • 0
  • NaN
  • "" (the empty string)
  • 0n (the bigint version of zero)
  • null
  • undefined
Boolean("hello"); // --- 1번
!!"world";   // -------- 2번
  • Boolean과 !! 의 차이는 무엇일까요?

    ⚠️ Boolean 의 경우 타입이 boolean이지만, !! 의 경우 타입이 “true”로 한정되어 나옵니다.

      // both of these result in 'true'
      Boolean("hello"); // type: boolean, value: true
      !!"world"; // type: true,    value: true

    어떤 이슈에서 Boolean과 !! 가지고 토론을 했는데 성능 부분을 제외하더라도 Boolean이 명시적이기에 Boolean을 사용하는 것을 권하고 있습니다.


특히 null 또는 undefined와 같은 값을 방지하기 위해 이 동작을 활용하는 것이 상당히 인기가 있습니다.
예를 들어 printAll 기능에 사용해 봅시다.

function printAll(strs: string | string[] | null) {
  if (strs && typeof strs === "object") {
    for (const s of strs) {
      console.log(s);
    }
  } else if (typeof strs === "string") {
    console.log(strs);
  }
}

strs가 truthy인지 확인하여 for문에서 발생할 오류를 방지할 수 있습니다.

TypeError: null is not iterable

하지만 원시 값에 대한 진실성 검사는 종종 사용자 이슈가 발생할 수 있습니다.

function printAll(strs: string | string[] | null) {
  // !!!!!!!!!!!!!!!!
  //  DON'T DO THIS!
  //   KEEP READING
  // !!!!!!!!!!!!!!!!
  if (strs) {
    if (typeof strs === "object") {
      for (const s of strs) {
        console.log(s);
      }
    } else if (typeof strs === "string") {
      console.log(strs);
    }
  }
}

이 코드는 어찌보면 맞는 코드처럼 보입니다.

그렇지만 strs 는 빈 문자열을 받게 될 경우 모든 코드를 감싸고 있는 if문을 통과하지 못하게 되는 불상사가 생길 수 있습니다.

동등성 축소 (Equality narrowing)

부제: 비교를 통한 타입 줄이기


TypeScript는 === , !==, ==!= 와 같은 switch 문과 동등성 검사를 사용하여 타입을 좁힙니다.

string 타입은 x와 y가 취할 수 있는 유일한 공통 타입이므로 TypeScript는 x와 y가 첫 번째 분기에서 문자열이어야 한다는 것을 알 수 있습니다.

== , != 의 TMI

==!= 를 사용하는 JavaScript의 느슨한 동등성 검사도 올바르게 좁혀집니다.
해당 연산자를 사용하면 null, undefined 모두 확인합니다.

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

function multiplyValue(container: Container, factor: number) {
  // Remove both 'null' and 'undefined' from the type.
  if (container.value != null) {
    console.log(container.value);
                           (property) Container.value: number
    // Now we can safely multiply 'container.value'.
    container.value *= factor;
  }
}

in 연산자를 사용하여 타입 축소하기 (The in operator narrowing)

JavaScript에는 개체에 이름이 있는 속성이 있는지 확인하는 연산자가 있습니다.
바로 in 연산자입니다.

TypeScript는 잠재적 유형을 좁히는 방법으로 이를 고려합니다.

아래 코드에선 animal이 Fish와 Bird 모두 받을 수 있기에 받은 animal 값 안에 구분할 수 있는 함수가 있는지 확인하는 방법으로 타입을 좁혔습니다.

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

function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    return animal.swim(); // Fish
  }

  return animal.fly();
}

instanceof 로 타입 축소하기 (instanceof narrowing)

JavaScript에는 값이 다른 값의 "인스턴스"인지 여부를 확인하는 연산자가 있습니다.

더 구체적으로 말하자면, JavaScript에서 x instanceof Foo는 x의 프로토타입 체인에 Foo.prototype이 포함되어 있는지 여부를 확인합니다.

타입 명제 사용하기 (Using type predicates)

타입 명제는 parameterName is Type 형식을 취합니다.
여기서 parameterName은 현재 함수 시그니처의 매개변수 이름이어야 합니다.

우리는 지금까지 축소를 처리하기 위해 기존 JavaScript 구조로 작업했지만
때로는 코드 전체에서 유형이 변경되는 방식을 보다 직접적으로 제어해야 합니다.

사용자 정의 유형 가드를 정의하려면 리턴 유형이 타입 명제인 함수를 정의하기만 하면 됩니다.

이 예시에서 타입 명제는 pet is Fish 부분입니다.

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

isFish가 일부 변수와 함께 호출될 때마다 TypeScript는 타입이 호환되는 경우 해당 변수를 특정 타입으로 좁힐 것입니다.

// Both calls to 'swim' and 'fly' are now okay.
let pet = getSmallPet();

if (isFish(pet)) {
	// pet type: Fish
  pet.swim();
} else {
	// pet type: Bird
  pet.fly();
}

제어 흐름 분석 (Control flow analysis)

지금까지 TypeScript가 특정 분기 내에서 어떻게 좁혀지는지에 대한 몇 가지 기본적인 예를 살펴보았습니다.
하지만 if, while, conditional 등에서 타입 가드를 찾는 것 이상의 일이 진행되고 있습니다.

function padLeft(padding: number | string, input: string) {
  if (typeof padding === "number") {
    return " ".repeat(padding) + input; //number
  }
  return padding + input; //string
}

padLeft는 첫 번째 if 블록 안에 리턴 구문이 있습니다.

TypeScript는 이 코드를 분석하여 padding 이 숫자인 경우 본문의 나머지 부분(return padding + input;)에 도달할 수 없음을 확인할 수 있었습니다.

그 결과 함수의 나머지 부분에 대해 number 타입을 제거하여 추론하였습니다.

도달 가능성을 기반으로 하는 이 코드 분석을 제어 흐름 분석이라고 하며,
TypeScript는 이 흐름 분석을 사용하여 타입 가드와 및 타입 좁히기를 사용할 수 있습니다.

변수를 분석하면 제어 흐름이 분리되고 다시 병합될 수 있으며
해당 변수는 각 지점에서 다른 타입을 갖는 것으로 관찰될 수 있습니다.

never 타입 (The never type)

never 는 타입의 가장 최하위 타입입니다.
TypeScript는 never 타입을 사용하여 존재해서는 안 되는 상태를 나타냅니다.

never일반적으로 함수의 리턴 타입으로 사용됩니다.

함수의 리턴 타입으로 never가 사용될 경우, 항상 오류를 출력하거나 리턴 값을 절대로 내보내지 않음을 의미합니다. 이는 무한 루프(loop)에 빠지는 것과 같습니다.

1. 예외를 던지는 함수

// 이 함수는 항상 예외를 던지므로, 반환 타입으로 **`never`**를 사용합니다.
function throwError(message: string): never {
  throw new Error(message);
}

2. 무한 루프를 도는 함수

// 이 함수는 항상 무한 루프를 돌기 때문에, 반환 타입으로 **never**를 사용합니다.
function infiniteLoop(): never {
  while (true) {
    // do something
  }
}

3. 타입 가드 함수

function assertIsDefined<T>(value: T): asserts value is NonNullable<T> {
  if (value === undefined || value === null) {
    throw new Error(`Expected 'value' to be defined, but received ${value}`);
  }
}
export function assert(condition: unknown, errorMessage?: string): **asserts** condition {
  if (!condition) {
    throw new Error(errorMessage || 'condition 조건이 true가 아닙니다.');
  }
}

function getNickname(name: string | null): string {
  name
  assert(name != null); // true

  return name; // string
}
profile
https://blog.areumsheep.vercel.app/ 으로 이동 중 🏃‍♀️
post-custom-banner

0개의 댓글