[TypeScript] Type Narrowing (타입 좁히기)

이선예·2023년 8월 10일
1

TypeScript

목록 보기
2/2
post-thumbnail

Type Narrowing(타입 좁히기)

유니온 타입처럼 여러 타입이 될 수 있는 경우, 타입이 확정되어 있지 않은 상태에 타입을 하나로 확정 시켜주는 것을 Narrowing 이라고 한다.

다음과 같은 함수가 있다고 생각해보자.

function padLeft(padding: number | string, input: string): string {
  return " ".repeat(padding) + input;
}
💡 string.repeat(number) : 인자로 받은 수 만큼 문자열을 반복해서 붙인 새로운 문자열 반환

Argument of type 'string | number' is not assignable to parameter of type 'number'.
Type 'string' is not assignable to type 'number'.Argument of type 'string | number' is not assignable to parameter of type 'number'.
Type 'string' is not assignable to type 'number'.

reapeat 메소드는 인자로 number타입만 받는데 padLeft함수 인자인 padding은 number와 string 둘 다 될 수 있기 때문에 에러가 난다.

먼저 padding이 number 타입인 경우 repeat메소드의 인자로 넣을 수 있게 예외처리를 해주면 에러를 방지할 수 있다.

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

if문으로 함수안에서 예외처리하는 것은 좋은 코드로 보이지 않을 수있지만,

TypeScript의 타입 시스템은 타입 안정성을 얻기 위해 어려운 방법을 사용하지 않고도 일반적인 JavaScript 코드를 가능한 쉽게 작성할 수 있게 하는것을 목표로 한다.

따라서, if/else, 삼항 연산자, 루프, truthiness check등과 같은 구조들이 모두 해당 타입에 영향을 미칠 수 있다.

typeof 연산자 return 값 : "string", "number", "bigint", "boolean", "symbol", "undefined", "object", "function"

typeof의 리턴값 중 null은 없다. 다음 예제를 보자.

function printAll(strs: string | string[] | null) {
  if (typeof strs === "object") {
    for (const s of strs) {
//Error : 'strs' is possibly 'null'.
      console.log(s);
    }
  } else if (typeof strs === "string") {
    console.log(strs);
  } else {
    // do nothing
  }
}

첫번째 if문을 통해 strs를 string | null 로 타입을 좁힐 수 있다. 하지만 null타입의 경우 iterable하지 않은 값으로 반복문의 인자로 사용할 수 없기 때문에 에러가 나는 것을 볼 수 있다.

Truthiness narrowing

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

if문은 조건을 부울(boolean) 값으로 "강제 형변환(coerce)"한 다음 결과가 참(true)인지 거짓(false)인지에 따라 분기를 선택한다.

false로 return하는 경우 : 0, NaN, "" (the empty string), 0n (the bigint version of zero), null, undefined

Boolean 함수에 넣거나, !!을 사용하여 강제 형변환할 수 있다. (후자의 경우 TypeScript는 좁은 리터럴 불리언 타입 true를 추론하며, 전자는 boolean 타입으로 추론된다.)

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

null 또는 undefined와 같은 값에 대한 가드(guard) 역할을 하는 데 사용

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);
  }
}

만약 다음과 같이 전체를 진리값 확인을 한다면, 빈 문자열의 경우 올바르게 처리를 하지 못할 수 있다.

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);
    }
  }
}

Equality narrowing

switch 문과 ===, !==, ==, != 같은 등호 비교를 사용하여 타입을 좁힌다.

function example(x: string | number, y: string | boolean) {
  if (x === y) {
    // We can now call any 'string' method on 'x' or 'y'.
    x.toUpperCase();  
//(method) String.toUpperCase(): string
    y.toLowerCase();
//(method) String.toLowerCase(): string
  } else {
    console.log(x);     
//(parameter) x: string | number
    console.log(y);       
//(parameter) y: string | boolean
  }
}

위 예제에서 x와 y가 모두 같다는 것을 확인했을 때, TypeScript는 x와 y가 가질 수 있는 유일한 공통 타입이 문자열이기 때문에 문자열로 타입을 좁힐 수 있다.

위의 printAll함수에서 null 을 다음과 같이 제거할 수 있다.

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

== , != 를 사용해서 타입 좁히기를 하는 경우, ==null 값이 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;
  }
}

The in operator narrowing

JavaScript에서 객체나, 객체의 프로토타입 체인에 특정 속성이 있는지 여부를 확인하는 in 연산자가 있다. TypeScript에서는 이를 타입 좁히기에 활용할 수 있다.

"value" in x : “value”는 문자열, x는 유니온 타입이다.

  • “true”분기 : ‘x’를 속성 “value”를 선택적 또는 필수 속성으로 가지고 있는 타입으로 좁힌다.
  • “false”분기 : ‘x’를 “value”속성이 없거나 선택적 또는 누락된 속성으로 가지고 있는 타입으로 좁힌다.
type Fish = { swim: () => void };
type Bird = { fly: () => void };
 
function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    return animal.swim();
  }
 
  return animal.fly();
}

다음의 예시처럼, 선택적속성은 true분기, false분기 모두 존재한다.

type Fish = { swim: () => void };
type Bird = { fly: () => void };
type Human = { swim?: () => void; fly?: () => void };
 
function move(animal: Fish | Bird | Human) {
  if ("swim" in animal) {
    animal;   
//(parameter) animal: Fish | Human
  } else {
    animal;
//(parameter) animal: Bird | Human
  }
}

instanceof narrowing

x instanceof Foo : x의 프로토타입 체인에 Foo.prototype이 포함되어있는지 확인

function logValue(x: Date | string) {
  if (x instanceof Date) {
    console.log(x.toUTCString()); 
//(parameter) x: Date
  } else {
    console.log(x.toUpperCase());         
//(parameter) x: string
  }
}

Assignments

변수에 값을 할당할 때, TypeScript는 오른쪽에서 왼쪽으로 타입을 확인하고 좁힌다.

let x = Math.random() < 0.5 ? 10 : "hello world!";
   //let x: string | number
x = 1;
console.log(x);
           //let x: number
x = "goodbye!";
console.log(x);
           //let x: string
x = true;
//Type 'boolean' is not assignable to type 'string | number'.
console.log(x);
           //let x: string | number

처음에 x를 선언할 때 타입이 string | number 이기 때문에, 이후에 선언된 타입에 해당하는 값을 할당할 수 있다. boolean타입을 할당했을 때 오류가 나는 것을 위의 예제를 통해 확인할 수 있다.

Control flow analysis

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

padLeft 함수는 첫 번째 if 블록 내에서 반환되어 나머지 부분(return padding + input;)이 숫자인 경우에는 실행되지 않는다.

따라서, padding의 타입에서 number를 제거하여 함수의 나머지 부분에서는 (string | number에서 string으로 좁혀짐) string 타입만 남게 된다.

이러한 도달 가능성에 기반한 코드 분석을 통해 타입을 좁히는 것을 "제어 흐름 분석(control flow analysis)"이라고 하며, TypeScript는 이러한 흐름 분석을 타입 가드와 할당을 만나면서 타입을 좁히는 데 사용한다. 변수가 분석될 때, 제어 흐름은 반복적으로 분할되고 다시 병합되며, 해당 변수는 각 지점에서 다른 타입을 가질 수 있다.

function example() {
  let x: string | number | boolean;
  x = Math.random() < 0.5;
  console.log(x);
               //let x: boolean
  if (Math.random() < 0.5) {
    x = "hello";
    console.log(x);
               //let x: string
  } else {
    x = 100;
    console.log(x);
               //let x: number
  }
  return x;
        //let x: string | number
}

Using type predicates

코드에서 타입이 어떻게 변경되는지 직접적으로 제어하기 위함

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

pet is Fish : 타입 프리디케이트로, parameterName is Type 형식을 가지며, parameterType은 현재 함수의 매개변수 이름이어야 한다.

isFish가 어떤 변수와 함께 호출될 때마다, TypeScript는 Fish | Bird 타입에 해당되는 경우 특정 타입으로 좁힌다.

// Both calls to 'swim' and 'fly' are now okay.
let pet = getSmallPet();
 
if (isFish(pet)) {
  pet.swim();
} else {
  pet.fly();
}

if 분기에서 pet이 Fish임을 알 뿐만 아니라, else 분기에서는 Fish가 아닌 경우이므로 Bird여야 한다는 것을 알고 있다는 것이다.

isFish 타입 가드를 사용하여 Fish | Bird 배열을 필터링하여 Fish 배열을 얻을 수 있다.

const zoo: (Fish | Bird)[] = [getSmallPet(), getSmallPet(), getSmallPet()];
const underWater1: Fish[] = zoo.filter(isFish);
// or, equivalently
const underWater2: Fish[] = zoo.filter(isFish) as Fish[];
 
// The predicate may need repeating for more complex examples
const underWater3: Fish[] = zoo.filter((pet): pet is Fish => {
  if (pet.name === "sharkey") return false;
  return isFish(pet);
});

이렇게 하면 zoo 배열에서 Fish인 요소들만 걸러진 underWater 배열을 얻을 수 있다.

Assertion Function

assert(someValue === 42) : someValue가 42가 아니면 throw an error 한다.

이를 사용해서 타입가드 코드를 작성할 수 있다.

function yell(str) {
  assert(typeof str === "string");
  return str.toUppercase();
  // Oops! We misspelled 'toUpperCase'.
  // Would be great if TypeScript still caught this!
}

위의 코드를 다음처럼 작성할 수도 있다.

function yell(str) {
  if (typeof str !== "string") {
    throw new TypeError("str should have been a string.");
  }
  // Error caught!
  return str.toUppercase();
}

하지만, 최종적으로 TypeScript의 목표는 기존의 JavaScript 구조를 최대한 파괴하지 않고 타입을 지정해주는 것이다.

이를 위해 TypeScript 3.7에서는 "assertion signatures"라는 새로운 개념을 도입했는데, 이는 이러한 단언 함수를 모델링하는 역할을 한다.

assertion signatures로 asserts condition , asserts val is string, val is string 가 있다.

  1. asserts condition
function yell(str) {
  assert(typeof str === "string");
  return str.toUppercase();
  //         ~~~~~~~~~~~
  // error: Property 'toUppercase' does not exist on type 'string'.
  //        Did you mean 'toUpperCase'?
}
function assert(condition: any, msg?: string): asserts condition {
  if (!condition) {
    throw new AssertionError(msg);
  }
}

여기서 사용된 assert 함수를 통해 yell 함수 내에서 문자열 타입인지 확인하고, 그 이후에 해당 조건이 true일 것이라는 확신을 가지고 toUppercase() 메서드를 호출하려고 하지만, 오타로 인해 오류가 발생한다.

  1. asserts val is string : 조건을 확인하지 않고, 특정 변수나 속성이 특정 타입을 가지고 있다고 TypeScript에게 알려준다.
function assertIsString(val: any): asserts val is string {
  if (typeof val !== "string") {
    throw new AssertionError("Not a string!");
  }
}
function yell(str: any) {
  assertIsString(str);
  // assertIsString 함수 호출 이후에, TypeScript knows that 'str' is a 'string'.
  return str.toUppercase();
  //         ~~~~~~~~~~~
  // error: Property 'toUppercase' does not exist on type 'string'.
  //        Did you mean 'toUpperCase'?
}
  1. val is string : type predicate와 유사한 assertion signature
function isString(val: any): val is string {
  return typeof val === "string";
}
function yell(str: any) {
  if (isString(str)) {
    return str.toUppercase();
  }
  throw "Oops!";
}

Discriminated unions 식별된 유니온

유니온 타입 내의 모든 유형이 문자열 타입을 가진 공통 속성을 포함하는 경우, TypeScript는 식별된 유니온으로 간주하고 유니온 멤버를 좁힐 수 있다.

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

interface Square {
  kind: 'square';
  sideLength: number;
}

type Shape = Circle | Square;

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'square':
      return shape.sideLength ** 2;
    default:
      // TypeScript는 모든 경우가 다루어졌음을 보장합니다
      throw new Error("알 수 없는 도형");
  }
}

kind 속성이 판별자 역할을 하며, TypeScript는 switch 문에서 이를 사용하여 shape 의 유형을 좁힌다.

The never type

유니온을 좁힐 때, 옵션을 줄여서 모든 가능성을 제거하고 아무것도 남지 않는 상태로 만들 수 있다. 이러한 경우에 TypeScript는 존재해서는 안 되는 상태를 나타내기 위해 never 타입을 사용한다. 다음 완전성 검사의 예제를 보자.

Exhaustiveness checking 완전성 검사

never 타입의 값은 모든 타입의 변수에 할당될 수 있지만, never 자체를 제외한 어떤 타입도 never타입의 변수에 할당될 수 없다.

이를 이용해서, switch문에서 default 블록에서 값을 never 로 할당하려는 경우, 모든 경우가 처리되었을 때 오류가 발생하지 않는다.

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

type Shape = Circle | Square | Triangle;

function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    default:
      const _exhaustiveCheck: never = shape;
      // Type 'Triangle' is not assignable to type 'never'.
      return _exhaustiveCheck;
  }
}

이렇게 함으로써 default 블록을 사용하여 switch 문이 모든 경우를 다루고 있는지 확인할 수 있다.


타입스크립트 핸드북을 번역하고, 정리한 내용입니다.
출처

profile
의미있는 훈련 기록 저장소

0개의 댓글