1장 타입스크립트 알아보기 - 2

soonrok·2023년 3월 18일
0
post-thumbnail

해당 포스팅은 인사이트의 이펙티브 타입스크립트라는 책을 독학하며 기록하는 글입니다.

아이템 3 : 코드 생성과 타입이 관계없음을 이해하기

큰 그림에서 보면, 타입스크립트 컴파일러는 두 가지 역할을 수행한다.

  • 최신 TS 및 JS를 브라우저에서 동작할 수 있도록 구버전의 JS로 트랜스파일(transpile)
  • 코드의 타입 오류 체크

컴파일 : 한 언어로 작서된 코드를 다른 언어로 변환하는 것 (C를 Assemply로 바꾸거나 Java를 Bytecode로 바꾸는 것 등)
트랜스파일 : 한 언어로 작성된 코드를 비슷한 수준의 추상화를 가진 다른 언어로 변환하는 것 (C++를 C로, Typescript를 Javascript로 바꾸는 것 등)
레퍼런스 : https://ideveloper2.tistory.com/166

여기서 이 두가지는 완벽하게 독립적으로 수행된다. 즉, 타입 오류가 있는 코드도 컴파일이 가능하다.
타입스크립트의 오류는 C나 자바 같은 언어들의 경고와 비슷한 것으로 문제가 될 만한 부분을 알려주지만 빌드를 멈주지는 않는다는 것이다.
만약 오류가 있을 때 컴파일하지 않으려면 tsconfig.json 설정 파일에서 noEmitOnError를 설정해주면 된다.

런타임에는 타입 체크가 불가능하다.

타입스크립트의 타입은 '제거 가능'하고 실제로 자바스크립트로 컴파일되는 과정에서 인터페이스, 타입 등 타입 관련된 구문은 제거된다. (컴파일된 자바스크립트 파일을 보면 아마 타입과 관련된 구문이 하나도 없을 것이다)

즉, 다음과 같은 코드는 오류가 난다. 만약 이미 타입스크립트로 개발을 하고 있는 사람들은 이런 오류를 많이 봤을 것이다. (일단 난 많이 봤다..)

interface Square {
  width: number;
}
interface Rectangle extends Square {
  height: number;
}
type Shape = Square | Rectangle;

function calculateArea(shape: Shape) {
  if (shape instanceof Rectangle) {
    				   ^
                       Error: 'Rectangle'은 형식만 참조하지만, 여기서는 값으로 사용되고 있습니다.
    return shape.width * shape.height;
  } else {
    return shape.width * shape.width;
  }
}

여기서 shape의 타입을 체크하는 이유는 Rectangle 타입의 경우 height 속성이 있고 아닌 경우에는 width만 있기 때문에 있다면 width * height를 반환하고 없다면 width * width를 반환하기 위해서이다.
하지만 타입스크립트를 자바스크립트로 컴파일하면서 타입구문들은 다 날라가기 때문에 값으로서 사용되고 있는 Rectangle을 사용할 수 없기에 오류가 나는 것이다. 이를 위한 해결 방법으로는 크게 3가지가 있다.

  • 속성체크
    어떠한 속성이 특정 타입에만 있는 것을 이용해서 타입을 판단하는 것이다. 즉, height 속성이 있다면 Rectangle, 없으면 Square 타입으로 판단한다. (지금까지 내가 사용했던 방법이다)
... 이전 코드 생략
function calculateArea(shape: Shape) {
  if ('height' in shape) {
    return shape.width * shape.height;
  } else {
    return shape.width * shape.width;
  }
}
  • 태그기법
    타입을 생성할 때, 해당 타입을 알 수 있는 속성을 하나 추가하는 방법이다. (이 속성을 태그라고 한다) 사용할 때는 태그의 값을 보고 해당 객체의 타입을 판단한다.
interface Square {
  tag: 'square'  // tag라는 속성이 추가됐다.
  width: number;
}
interface Rectangle extends Square {
  tag: 'rectangle'  // tag라는 속성이 추가됐다.
  height: number;
}
type Shape = Square | Rectangle;

function calculateArea(shape: Shape) {
  if (shape.tag === 'rectangle') {  // tag 값을 이용해 타입판단
    return shape.width * shape.height;
  } else {
    return shape.width * shape.width;
  }
}
  • class 사용
    타입(런타임 접근 불가)과 값(런타임 전급 가능)을 둘 다 사용하는 기법으로 타입을 interface가 아니라 class로 만드는 것이다.
class Square {
  constructor(public width: number) {}
}
interface Rectangle extends Square {
  constructor(public width: number, public height: number) {
    super(width);
  }
}
type Shape = Square | Rectangle;  // 1️⃣

function calculateArea(shape: Shape) {
  if (shape instanceof Rectangle) {  // 2️⃣
    return shape.width * shape.height;
  } else {
    return shape.width * shape.width;
  }
}

이 경우, 각 타입들이 1️⃣에서는 타입으로 참조되었지만, 2️⃣에서는 값으로 참조됐기 때문에 오류가 나지 않는다. (각 부분에 대해서 어떻게 참조되는지 구분하는 방법은 아이템 8에서 다룬다)

타입 연산은 런타임에 영향을 주지 않는다.

다음 코드를 보자.

function asNumber(value: number | string): number {
  return value as number;
}

이 함수는 인자로 number 타입이나 string 타입의 값을 받아서 타입을 number로 변환해 반환하는 함수이다. 하지만 이 함수가 우리가 의도한대로 동작할까??

정답은 '아니다'

누누히 이야기했듯이 타입스크립트 파일을 자바스크립트 파일로 컴파일하게 되면 타입과 관련된 구문들은 모두 사라진다. 즉, 위의 코드는 아래와 같아진다.

function asNumber(value) {
  return value;
}

그냥 값을 받아서 그대로 반환하는 함수가 됐다. as number와 같은 타입 연산은 런타임 동작에 아무런 영향을 미치지 않는다는 것이다.
우리가 의도한대로 코드가 동작하기 위해서는 다음과 같이 코드를 작성해야 한다.

function asNumber(value: number | string): number {
  return typeof(value) === 'string' ? Number(value) : value;
}

런타임 타입을 선언된 타입과 다를 수 있다.

우리가 어떠한 값에 타입을 선언해 줬을 때, 그 값이 항상 그 타입을 가진다고 보장할 수 있을까??

이것 또한 '아니다'

가장 흔하게 생각할 수 있는 경우가 API의 사용이다. 우리가 API의 response 타입을 정의해서 사용할 때, API의 명세를 잘못 파악해서 실수할수도 있고 API가 배포 후 변경되었을 수도 있다. 이렇게 우리는 선언된 타입이 런타임 환경에서 언제든지 달라질 수 있다는 것을 명심하고 방어적인 코드를 짜야 한다.


해당 아이템에서 추가적으로 알고 있어야할 내용은 두 가지 정도가 더 있다.

  • 타입스크립트 타입으로는 함수를 오버로드 할 수 없다.
    타입스크립트에서는 타입과 런타임의 동작이 무관(독립)하기 때문이다.
  • 타입스크립트 타입은 런타임 성능에 영향을 주지 않는다.
    타입과 관련된 구문들은 컴파일하면서 모두 사라지기 때문이다. 하지만 '런타임' 오버헤드가 없는 대신, '빌드타임' 오버헤드가 존재한다.

함수 오버로딩 : 같은 이름을 가진 함수가 매개변수의 타입과 인자에 따라 다르게 동작하는 것

아이템 4 : 구조적 타이핑에 익숙해지기

자바스크립트는 본질적으로 덕 타이핑(duck typing) 기반이다.

덕 타이핑이란, 객체가 어떤 타입에 부합하는 변수와 메서드를 가질 경우 그 객체를 해당 타입에 속하는 것으로 간주하는 방식이다.
"만약 어떤 새가 오리처럼 걷고, 헤엄치고, 꽥꽥거리는 소리를 낸다면 나는 그 새를 오리라고 부를 것이다."라는 덕 테스트에서 유래됐다.

타입스크립트는 자바스크립트의 런타임 동작을 모델링하기 때문에 덕 타이핑 동작도 그대로 모델링한다. 아래 예시를 보자.

interface Vector2D {
  x: number;
  y: number;
}
interface NamedVector {
  name: string;
  x: number;
  y: number;
}

function calculateLength(v: Vector2D) {
  return Math.sqrt(v.x * v.x + v.y * v.y);
}

const v: NamedVector = {
  name: 'example',
  x: 3,
  y: 4
}
console.log(calculateLength(v));  // 5

분명 calculateLength 함수는 Vector2D 타입의 매개변수를 받아 동작하는 함수이다. 하지만 우리는 NamedVector 타입의 변수를 넘겨줬는데도 정상적으로 값을 반환했다. 왜일까??

바로 NamedVector 타입에도 Vector2D 타입에 해당하는 x 속성과 y 속성이 있기 때문에 Vector2D와 호환되었기 때문이다. 이를 '구조적 타이핑'이라고 한다.

보통 우리는 함수를 작성할 때, 호출에 사용되는 매개변수의 속성들이 매개변수의 타입에 선언된 속성만을 가질 거라고 생각한다. 하지만 이러한 타입은 '봉인된(sealed)' 또는 '정확한(precise)' 타입이라고 불리며 타입스크립트의 타입 시스템에서는 표현할 수 없다. 때문에 타입스크립트에서 타입은 좋든 싫든 '열려(open)'있다. 아래 코드를 보자.

interface Vector2D {
  x: number;
  y: number;
}
interface Vector3D {
  x: number;
  y: number;
  z: number;
}

function calculateLength(v: Vector2D) {
  return Math.sqrt(v.x * v.x + v.y * v.y);
}

const v: Vector3D = {
  x: 3,
  y: 4,
  z: 5
}
console.log(calculateLength(v));  // 5 (5√2에 해당하는 값이 정상이다.)

Vector3D 타입은 x, y, z 속성을 가지고 있으며 이 벡터의 길이는 실제로 각 속성값의 제곱의 합에 루트를 씌운 값이다.

하지만 벡터 v를 calculateLength 함수의 인자로 넘겨주니까 해당 함수는 v가 3차원 벡터라는 것을 알아채지 못하고 일단 x 속성과 y 속성이 있기 때문에 2차원 벡터로 간주해 2차원에서의 벡터의 길이 값을 반환했다.

구조적 타이핑이 편리하기는 하지만 위와 같은 문제를 야기할 수 있다. 혹 위의 문제를 보고 다음과 같이 생각할 수 있다.

그럼 인자로 들어온 객체를 순회하면서 객체에 있는 모든 값의 제곱을 합한 뒤에 루트를 씌워주면 되는거 아냐??

당연히 '아니다'

타입스크립트는 타입에 대해서 항상 열려있기 때문에 해당 객체가 어떤 값을 가지고 있을지 우리가 단정할 수 없다. 아래와 같이 값이 들어올 수도 있다.

const v = {
  x: 3,
  y: 4,
  z: 5,
  name: 'example'
}

만약 우리가 위 객체를 함수의 인자로 넘겨 함수에서 해당 객체를 순회했다면 name 속성때문에 NaN 값이 나왔을 것이다. 따라서 이런 경우에는 귀찮지만 각 속성을 각각 더하는 구현이 더 낫다. (v.x 제곱 + v.y 제곱 + v.z 제곱)


구조적 타이핑의 장점은 다음과 같다.

  • 테스트를 작성할 때 유리하다.
    추상화를 함으로써, 로직과 테스트를 특정한 구현으로부터 분리할 수 있기 때문이다.
  • 라이브러리 간의 의존성을 완벽히 분리할 수 있다.
profile
I Will be Relaxed Person

0개의 댓글