[220323_TIL] typescript를 배워보자 | Narrowing

jisu·2022년 3월 23일
0

typescript

목록 보기
3/4
post-thumbnail

전체 코드는 git 에서 확인 가능합니다. 작성된 내용은 ts 공식문서를 학습하며 주관적으로 재구성한 내용으로, 오류가 있을 수 있습니다. 잘못된 내용은 댓글로 알려주시면 감사히 수정하겠습니다 : )

이전 장에서 유니온 타입을 사용하여 둘 이상의 타입을 파라미터로 받는 함수를 알아보았다. 다음의 예시는 왼쪽에 패딩을 추가하는 함수다. 파라미터가 숫자인 경우, input 왼쪽에 숫자만큼 공백을 준다. 파라미터가 문자일 경우에는 왼쪽에 패딩에 해당하는 글자를 붙이고자한다.

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

원하는 로직을 수행하기 위해 조건분기로 type을 검사하고 있다. 이렇게 하는 것은 일반적인 javascript와 동일해보인다. 하지만 ts는 위 조건문 형태를 타입가드로 인식하여 가이드한다.

number

string

위와 유사한 방식으로 ts가 타입 축소를 이해하는 방법에는 몇가지 구성이 있다.

typeof 타입가드

js에서 기본적으로 제공하는 typeof 연산자는 다음과 같은 반환값(문자열)을 가진다.

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

    단, null의 typeof 반환값은 object 라는 것에 주의! 그렇기 때문에 null 값을 판별할 다른 방법이 필요하다.

Truthiness narrowing

번역이 이상하다. 진실성 축소?
위에서 언급한 null 값등을 축소할 수 있는 좋은 방법은 조건문에서 && , ||, ! 등을 사용하거나 파라미터 자체의 참, 거짓을 판별하는 것이다.

js에서 조건문에 조건으로 적용할 때 false 에 해당하는 것들은 다음과 같다.

  • 0
  • NaN
  • "" (빈문자열)
  • 0n(bigint 제로 버전)
  • null
  • undefined

위 값들은 조건문에서 false로 반환되기 때문에 타입 축소에 활용할 수 있다.

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 && 부분이 없었다면, nullobject 이기 때문에 오류가 발생하지만 조건을 추가함으로서 오류를 방지할 수 있다.

자주하는 실수

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

위 함수는 자칫 보면 null 로 인한 오류를 피하기 위해 전체를 조건문으로 래핑한 올바른 코드로 보인다. 하지만 이렇게 하면 빈문자열도 false로 간주되어 원하는 작업을 하지 않는다. 오직 null만을 위한 것이라면 전자로 수정해야 한다.

Equality narrowing

=== 으로 암묵적 형변환 없이 완전히 동일한(타입과 값 모두 같은) 값을 판별하여 축소할 수도 있다. 이외에도 !==, ==, != 를 적절히 사용하여 타입을 축소할 수 있다. 조건문이 아니라 스위치문을 사용하는 것도 좋다.

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

위 예제에서 파라미터 x, y가 각각 가질 수 있는 타입이 2개씩이라 둘의 조합은 총 4개가 될 수 있다. 하지만 첫번째 조건문을 통해 x와 y가 완전히 동일한 경우, 즉 둘다 string 으로 타입도 같고 값도 같은 단 한가지 경우를 분기할 수 있다. 그리고 뭐라도 다르면 else 문으로 빠진다.

위에서 자주하는 실수로 언급한 내용도 등호를 이용해 다음과 같이 바꿀 수 있다. 값 자체가 false인 조건에서 null이 아닌 조건으로 조건을 좀 더 세부적으로 나눴다.

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

이렇게 수정하면 빈문자열도 true가 되어 조건문이 원하는대로 동작하게 된다.

느슨한 일치가 필요할 때

js에서는 대부분 완벽히 동일함을 판별하기 위해 등호 세개를 많이 쓴다. 하지만 특수한 경우, 타입 검사에서 등호 두개가 유용할 때가 있다.

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);
    // Now we can safely multiply 'container.value'.
    container.value *= factor;
  }
}

Countervalue 는 셋 중 하나인데, 위 조건문에서 != 을 사용하여 null인지 판단한다. 이때 == 등호 두개는 암시적 형변환을 해서 undefinednull 로 판단한다. 그래서 하나의 조건문으로 둘다 거를 수 있다. (물론 typeof number 하는 게 더 나아 보이긴 한다)

결론은 등호를 적절히 사용해서 타입축소를 할 수 있다는 것이다.

The in operator narrowing

js에서 in은 이터러블 객체에서 어떤한 속성이 있는지 없는지를 판단하는 연산자이다. 이를 사용해서 타입 축소를 할 수 있다.

type Fish = { swim: () => void };
type Bird = { fly: () => void };
 
function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    return animal.swim();
  }
 
  return animal.fly();
}

instanceof narrowing

js에서는 값이 다른 값의 인스턴스 인지를 판단하는 instanceof 라는 연산자가 있다. x instanceof Foo 는 Foo 라는 프로토타입체인 안에 x라는 인스턴스가 있나 확인하는 것이다. 프로토타입과 인스턴스는 갱장히 중요한 개념이니 여기 참조.

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

Assignments

ts는 타입을 명시하지 않아도, 변수를 선언하는 첫 당시의 값을 바탕으로 타입을 지정한다.

let x = Math.random() < 0.5 ? 10 : "hello world!"; // 선언할 때 let x: string | number 으로 지정
x = 1; // number 가능
console.log(x);

x = "goodbye!"; // string 가능
console.log(x);

x = true; // Type 'boolean' is not assignable to type 'string | number'.

Control flow analysis

유니온 타입으로 선언된 변수는 조건문과 반복문들을 거치면서 중간중간 타입이 바뀌기도 하고, 특정 타입으로는 다시 돌아가지 않기도 한다. 제어 흐름에 따라 ts는 그때 그때의 타입을 추론하여 알려준다.
Control flow analysis

Using type predicates

js 구문이외에 보다 직접적으로 타입을 축소할 때 ts의 타입 예측을 사용할 수 있다.

타입 예측은 is 키워드를 사용할 수 있다. 일반적으로 true, false를 반환하는 물고기임을 확인하는 다음의 함수가 있다. 이 함수를 통해 true가 나온다면 pet은 물고기였음이 자명하다. 이때 is를 통해 pet을 Fish로 예측 가능하게 명시하는 것이다.

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

위 처럼 타입검사를 하면, ts가 자동적으로 아래 조건문에서 true로 분기된 pet에 대해서 타입을 Fish로 가이드 해준다.

let pet = getSmallPet();
 
if (isFish(pet)) {
  pet.swim();
} else {
  pet.fly();
}

Discriminated unions

원 또는 사각형의 모양을 나타내는 interface가 있다고 하자. 이는 둘중 어느것일지 모르니 반지름과 길이 둘다 있을수도, 없을 수도 있다고 정의했다.

interface Shape {
  kind: "circle" | "square";
  radius?: number;
  sideLength?: number;
}

그리고 이 모양의 면적을 구하는 함수를 다음과 같이 정의했다. 하지만 이는 당연하게 Object is possibly 'undefined'. 라는 오류를 뱉는다. shaperadius가 없을 수 있기 때문이다.

function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2;
}

아래처럼 바꾸면 어떨까? 안타깝게도 여전히 같은 오류를 나타낸다. kind가 circle이여도 여전히 radius는 ?로 선언되어 있을수도 있고 없을수도 있는 속성이기 때문이다.

function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2;
  }
}

그럼 원일때는 반드시 반지름이 있다고 우기면 어떨까? 아래 처럼 !를 써서 radius가 반드시 있으니까 ts 조용히 하라고 하면 오류는 없앨 수 있다. 하지만 인간은 같은 실수를 반복하고, 개발자는 radius를 안써버릴지 모른다. 이땐 런타임에서 에러가 나겠지...

function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius! ** 2;
  }
}

그래서 타입을 정의하는 것 자체를 수정해보자. 아래처럼 특정 모양이 필수적으로 가져야 하는 속성을 분리하여 원과 사각형을 분리한다.

interface Circle {
  kind: "circle";
  radius: number;
}
 
interface Square {
  kind: "square";
  sideLength: number;
}
 
type Shape = Circle | Square;

그리고 두 타입의 유니온 타입을 type 으로 선언해준다.

type 대신 interface 는 안된다. 이전 장 참고

위 처럼 수정하면, Shape 라는 타입은 공통적으로 kind 라는 속성을 가지고, 각각 고유의 필수적인 속성도 가지게 되었다.

function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2;
  // Property 'radius' does not exist on type 'Shape'.
  // Property 'radius' does not exist on type 'Square'.
}

여전히 위 코드는 shape이 사각형일수도 있으니 오류를 보여주지만, 그 내용이 보다 정확하다.

그리고 이렇게 수정하면, 오류없이 ts가 타입을 잘 감지하게 된다. if문 대신 switch로 해도 동일하게 잘 작동한다.

function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2;
  }
}

Never

절대 나오지 않을 경우를 정의할 수 있다. 혹시 모를 상황에 대비하여 마지막 안전망을 하나 더하는 셈이다.

Shape 는 원 또는 사각형이기 때문에 default 에 들어갈 일은 없다. 하지만 만에 하나 Hoxy 모를 상황에서 아무것도 남지 않은 지점까지 옵션을 줄일 수 있다. 절대 안나올 타입이라는 의미.

type Shape = Circle | Square;
 
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;
      return _exhaustiveCheck;
  }
}

이렇게만 보면 크게 쓸모 없어보이지만 아래 처럼 활용할 수 있다. 분기가 여러개여서 모든 조건에 맞는 분기를 했다고 생각하고, never을 찍은 경우. 남은 분기가 있다면, ts가 아직이라고 알려준다.

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

요약

  • 일반적으로 js에서 하듯 조건문으로 타입을 축소할 수 있다.
  • in, instanceof, typeof 등 각종 연산자도 사용 가능.
  • 타입 예측으로 타입검사를 위한 함수의 리턴값을 명시할 수 있다.
  • Discriminated unions 을 적절히 사용하여 중복 속성을 피하고 interface를 그룹화 할 수 있다.
  • Never 을 사용하면 휴먼에러를 줄일 수 있다.

Reference

profile
(이제부터라도) 기록하려는 프론트엔드 디벨로퍼입니다 XD

0개의 댓글