[이펙티브 타입스크립트] 1장 타입스크립트 알아보기
Prog. Langs & Tools/TypeScript

[이펙티브 타입스크립트] 1장 타입스크립트 알아보기

아이템 1. 타입스크립트와 자바스크립트 관계 이해하기

타입스크립트는 문법적으로 자바스크립트의 상위집합.

→ 자바스크립트 프로그램에 문법 오류가 없다면, 유효한 타입스크립트 프로그램이라고 할 수 있다.

→ 자바스크립트 프로그램에 어떤 이슈가 존재한다면 문법 오류가 아니라도 타입 체커에게 지적당할 가능성이 높다.

→ 문법의 유효성과 동작의 이슈는 독립적인 문제

타입 시스템의 목표 중 하나는 런타임에 오류를 발생시킬 코드를 미리 찾아내는 것이다.

→ 그러나 타입 체커가 모든 오류를 찾아내지는 않음

→ 타입 체커를 통과하면서 런타임 오류를 발생시키는 코드는 충분히 존재.

타입스크립트 타입 시스템은 자바스크립트의 런타임 동작을 '모델링' 한다.

const x = 2 + '3'; // 정상, string
const y = '2' + 3; // 정상, string

이 예제는 다른 언어였다면 런타임 오류가 될 만한 코드이다. 하지만 타입스크립트의 타입 체커는 정상으로 인식한다.

아이템 2. 타입스크립트 설정 이해하기

타입스크립트의 noImplicitAny 설정은 변수들이 미리 정의된 타입을 가져야 하는지 여부를 제어한다.

→ 해제하면 다음과 같은 코드도 유효하다.

function add(a, b) {
  return a + b;
}

// noImplicitAny : false

// function add(a: any, b:any): any {
// ...

any 타입을 매개변수에 사용하면 타입 체커는 속절없이 무력해진다. any는 유용하지만 매우 주의해서 사용해야 한다.

any를 코드에 넣지 않았지만, any 타입으로 간주하는 경우 이를 암시적 any(implicit any)라고 한다.

타입스크립트는 타입 정보를 가질 때 가장 효과적이기 때문에, 되도록 noImplicitAny를 설정해야 한다.

strictNullChecks는 null과 undefined가 모든 타입에서 허용되는지 확인하는 설정이다.

// strictNullChecks : false
const x: number = null; // 정상

// strictNullChecks : true
const x: number = null; // 에러, 'null' 형식은 'number' 형식에 할당할 수 없다. 

undefined는 객체가 아닙니다같은 런타임 오류를 방지하기 위해서는 strictNullChecks 를 설정하는 것이 좋다.

컴파일러가 null 또는 undefined 를 제거할 수없는 경우, 타입 선언 연산자를 사용하여 수동으로 제거할 수 있다. 구문은 변수 뒤에 ! 를 붙이는 것입니다. identifier! 는 식별자의 타입에서 null 과 undefined 를 제거한다.

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

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

  1. 최신 타입스크립트/자바스크립트를 브라우저에서 동작할 수 있도록 구버전의 자바스크립트를 트랜스파일(transpile)한다.
  2. 코드의 타입 오류를 체크한다.

이 두 가지는 완전히 독립적이다.

타입 오류가 있는 코드도 컴파일이 가능하다.

타입체크와 컴파일이 동시에 이루어지는 C나 자바 같은 언어를 사용하던 사람이라면 이러한 상황이 황당하다. 타입스크립트 오류는 C나 자바 같은 언어들의 경고(warning)와 비슷하다. 문제가 될 만한 부분을 알려주지만, 그렇다고 빌드를 멈추지는 않는다.

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

interface Square {
  width: number;
}

interface Rectangle extends Square {
  height: number;
}

type Shape = Square | Rectangle;

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

instanceof 체크는 런타임에 일어나지만, Rectangle은 타입이기 때문에 런타임 시점에 아무런 역할을 할 수 없다. 타입스크립트의 타입은 '제거 가능(eraseable)'하다. 실제로 자바스크립트로 컴파일되는 과정에서 모든 인터페이스, 타입, 타입 구문은 그냥 제거되어 버린다.

위의 코드에서 다루고 있는 shape 타입을 명확하게 하려면, 런타임에 타입 정보를 유지하는 방법이 필요하다. 하나의 방법은 height 속성이 존재하는지 체크해 보는 것이다.

function calculateArea(shape: Shape) {
  if ('height' in shape) {
    shape; // 타입이 Rectangle
    return shape.width * shape.height;
  } else {
    shape; // 타입이 Square
    return shape.width * shape.width;
  }
}

속성 체크는 런타임에 접근 가능한 값에만 관련되지만, 타입 체커 역시도 shape의 타입을 Rectangle로 보정해 주기 때문에 오류가 사라진다.

타입(런타임 접근 불가)과 값(런타임 접근 가능)을 둘 다 사용하는 기법도 있다. 타입을 클래스로 만들면 된다. Square와 Rectangle을 클래스로 만들면 오류를 해결할 수 있다.

class Square {
  constructor(public width: number) {}
}

class Rectangle extends Square {
  constructor(public width: number, public height: number) {
    super(width);
  }
}

type Shape = Square | Rectangle;

function calculateArea(shape: Shape) {
  if (shape instanceof Rectangle) {
    shape; // 타입이 Rectangle
    return shape.width * shape.height;
  } else {
    shape; // 타입이 Square
    return shape.width * shape.width;
  }
}

인터페이스는 타입으로만 사용 가능하지만, Rectangle을 클래스로 선언하면 타입과 값으로 모두 사용할 수 있으므로 오류가 없다.

타입스크립트 타입으로는 함수를 오버로드 할 수 없다.

C++ 같은 언어는 동일한 이름에 매개변수만 다른 여러 버전의 함수를 허용한다. 이를 '함수 오버로딩' 이라고 한다. 그러나 타입스크립트에서는 타입과 런타임의 동작이 무관하기 때문에 함수 오버로딩은 불가하다.

function add(a: number, b: number) { return a + b; } // ~~~ 중복된 함수 구현입니다.
function add(a: string, b: string) { return a + b; } // ~~~ 중복된 함수 구현입니다.

타입스크립트가 함수 오버로딩 기능을 지원하기는 하지만, 온전히 타입 수준에서만 동작한다. 하나의 함수에 대해 여러 개의 선언문을 작성할 수 있지만, 구현체(implementation)는 오직 하나뿐이다.

function add(a: number, b: number): number;
function add(a: string, b: string): string;

function add(a, b) {
  return a + b;
}

const three = add(1,2); // 타입이 number;
const twelve = add('1','2'); // 타입이 string;

add에 대한 처음 두 개의 선언문은 타입 정보를 제공할 뿐이다. 이 두 선언문은 타입스크립트가 자바스크립트로 변환되면서 제거되며, 구현체만 남게 된다.

타입스크립트 타입은 런타임 성능에 영향을 주지 않는다.

타입과 타입 연산자는 자바스크립트 변환 시점에 제거되기 때문에, 런타임의 성능에 아무런 영향을 주지 않는다.

'런타임' 오버헤드가 없는 대신, 타입스크립트 컴파일러는 '빌드타임' 오버헤드가 있다.

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

자바스크립트는 본질적으로 덕 타이핑(duck typing) 기반이다. 만약 어떤 함수의 매개변수 값이 모두 제대로 주어진다면, 그 값이 어떻게 만들어 졌는지 신경쓰지 않고 사용한다.

덕 타이핑이란, 객체가 어떤 타입에 부합하는 변수와 메서드를 가질 경우 객체를 해당 타입에 속하는 것으로 간주하는 방식이다.

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

// 벡터의 길이를 구하는 함수 (2D)
function calculateLength(v: Vector2D) {
  return Math.sqrt(v.x * v.x + v.y * v.y);
}

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

// 3D 벡터의 길이를 1로 만드는 정규화 함수
function normalize(v: Vector3D) {
  const length = calculateLength(v);
  return {
    x: v.x / length,
    y: v.y / length,
    z: v.z / length,
  };
}

// 함수 normalize는 1보다 조금 더 긴 길이를 가진 결과를 출력
normalize({x: 3, y: 4, z: 5})
// {x: 0.6, y: 0.8, z: 1}

여기서 타입스크립트는 오류를 잡지 못했다. 그 이유는 calculateLength는 2D 벡터를 기반으로 연산하는데, 버그로 인해 normalize가 3D 벡터로 연산되었기 때문이다. z가 정규화 과정에서 무시되었다. 타입 체커는 이 문제를 잡아내지 못했다.

이 원인을 조금 더 살펴보면, Vector3D와 호환되는 {x, y, z} 객체로 calculateLength를 호출하면, 구조적 타이핑 관점에서 x, y가 있어서 Vector2D와 호환된다. 따라서 오류가 발생하지 않았고, 타입 체커가 문제로 인식하지 않았다.

구조적 타이핑은 클래스와 관련된 할당문에서도 당황스러운 결과를 보여줄 수 있다.

class C {
  foo: string;
    constructor(foo: string) {
        this.foo = foo;
    }
}

const c = new C('instance of C');
const d: C = { foo: 'object literal' }; // 정상

d가 C 타입에 할당되는 이유를 살펴보자. d는 string 타입의 foo 속성을 가진다. 그리고 하나의 매개변수로 호출되는 생성자(Object.prototype으로부터 비롯된)를 가진다. 그래서 구조적으로는 필요한 속성과 생성자가 존재하기 때문에 문제가 없다.

만약 C의 생성자에 단순 할당이 아닌 연산 로직이 존재한다면, d의 경우는 생성자를 실행하지 않으므로 문제가 발생하게 된다. 이러한 부분이 C타입의 매개변수를 선언하여 C 또는 서브클래스임을 보장하는 C++이나 자바 같은 언어와 매우 다른 특징이다.

아이템 5. any 타입 지양하기

일부 특별한 경우를 제외하고는 any를 사용하면 타입스크립트의 수많은 장점을 누릴 수 없게 된다. 부득이하게 any를 사용하더라도 그 위험성을 알고 있어야 한다.

  • any 타입에는 타입 안정성이 없다.
  • any는 함수 시그니처를 무시한다.
    • 아래의 코드에서 birthDate 매개변수는 string이 아닌 Date 타입이어야 한다. any 타입을 사용하면 calculateAge의 시그니처를 무시하게 된다. 자바스크립트에서는 종종 암시적으로 타입이 변환되기 때문에 이런 경우 특히 문제가 될 수 있다.
    function calculateAge(birthDate: Date): number {
      // ...
    }
    
    let birthDate: any = '1900-01-01';
    calculateAge(birthDate); // 정상
  • any 타입에는 언어 서비스가 적용되지 않는다.
  • any 타입은 코드 리팩터링 때 버그를 감춘다.
  • any는 타입 설계를 감춰버린다.
  • any는 타입시스템의 신뢰도를 떨어뜨린다.
    • any 타입을 쓰지 않으면 런타임에 발견될 오류를 미리 잡을 수 있고 신뢰도를 높일 수 있다.

참고자료

<이펙티브 타입스크립트> 댄 밴더캄 저, 프로그래밍 인사이트 (2021)