이펙티브 타입스크립트 - 1장

Sohee Park·2023년 1월 4일
8
post-thumbnail

타입스크립트란 무엇이고, 타입스크립트를 어떻게 여겨야 하는지, 자바스크립트와는 어떤 관계인지 등 주목할만한 점을 미리 짚고 넘어간다.

ITEM 01 타입스크립트와 자바스크립트의 관계 이해하기

타입스크립트는 자바스크립트의 상위 집합(superset)이다.
자바스크립트 프로그램에 어떤 이슈가 존재한다면, 문법 오류가 아니더라도 타입체커에게 지적당할 가능성이 높다. (문법의 유효성과 동작의 이슈는 다른 이슈)

하지만, 아래와 같은 명제에서는 조금 다를 수 있다.

모든 자바스크립트 프로그램은 타입스크립트 (O)
모든 타입 스크립트 프로그램은 자바스크립트 (x)

타입스크립트가 타입을 명시하는 추가적인 문법을 가지기 때문에, 타입스크립트 프로그램이더라도 자바스크립트가 아닌 프로그램으로 존재할 수 있다.

타입시스템의 목표 중 하나는 런타임에 오류를 발생시킬 코드를 미리 찾아내는 것이다. 타입스크립트가 '정적'타입 시스템이라는 것은 바로 이런 특징을 말한다. 단, 타입 체커가 모든 오류를 찾아내지는 않는다.

// in JS - 문제 없음
let city = 'new york city';
console.log(city.toUppercase());


// in TS - 아래와 같은 메세지를 실행하기 전에 바로 확인할 수 있다.
let city = 'new york city';
console.log(city.toUppercase());
              // ~~~~~~~~~~~ Property 'toUppercase' does not exist on type
              //             'string'. Did you mean 'toUpperCase'?

이처럼 타입을 선언하지 않아도 오류를 찾아낼 수 있지만, 타입을 선언하면 작성된 코드의 동작과 의도가 다른 부분을 찾아낼 수 있다.

const states = [
  {name: 'Alabama', capital: 'Montgomery'},
  {name: 'Alaska',  capital: 'Juneau'},
  {name: 'Arizona', capital: 'Phoenix'},
  // ...
];
for (const state of states) {
  console.log(state.capitol); // in JS - undefined
                  //~~~~~~~ Property 'capitol' does not exist on type
                 //         '{ name: string; capital: string; }'.
                 //         Did you mean 'capital'?
}

타입스크립트의 타입 시스템은 자바스크립트의 런타임 동작을 '모델링'한다.
아래 변수들은 타입을 지정하지 않았지만, 런타임되었을 때 string 타입이므로 아래 변수도 타입이 string으로 모델링되어 에러가 발생하지 않는다.

const x = 2 + '3';  // OK, type is string
const y = '2' + 3;  // OK, type is string

반대로 아래는 런타임일 때 에러가 발생하지 않는데 타입체커는 문제로 표시한다.

const a = null + 7;  // Evaluates to 7 in JS
       // ~~~~ Operator '+' cannot be applied to types ...
const b = [] + 12;  // Evaluates to '12' in JS
       // ~~~~~~~ Operator '+' cannot be applied to types ...
alert('Hello', 'TypeScript');  // alerts "Hello"
            // ~~~~~~~~~~~~ Expected 0-1 arguments, but got 2

따라서 이런 방식으로 더하거나, 불필요한 매개변수를 추가해서 함수 호출하는 것을 당연하게 여긴다면 차라리 타입스크립트를 사용하지 않는게 더 나을 수 있다. 위와 같은 일이 발생하는 이유는 타입스크립트가 이해하는 값의 타입과 실제 값에 차이가 있기 때문이다. 타입스크립트의 타입 시스템은 정적 타입의 정확성을 보장해 줄 것 같지만 그렇지 않다. 애초에 그런 목적으로 만들어진 것도 아니다.

ITEM 02 타입스크립트 설정 이해하기

타입스크립트 컴파일러는 매우 많은 설정을 가지고 있고, $ tsc [--option] [filename]형태로 커맨드 라인에서 사용할 수 있다. 또는 tsconfig.json 설정 파일에서도 가능하다.

설정파일은 tsc --init을 실행하거나 실제로 파일을 생성하면 되는데, 설정을 제대로 사용하려면 noImplictAnystrictNullChecks를 이해해야 한다.

noImplictAny

변수들이 미리 정의된 타입을 가져야 하는 지 여부를 제어한다.

// tsConfig: {"noImplicitAny":false}
function add(a, b) {
  return a + b;
}

// tsConfig: {"noImplicitAny":true}
function add(a, b) {
          // ~    Parameter 'a' implicitly has an 'any' type
          //    ~ Parameter 'b' implicitly has an 'any' type
  return a + b;
}

이 경우 에러를 해결하려면 명시적으로 any라고 써주거나 다른 타입을 넣어주면 된다.
이 설정을 사용하면 문제 발견도 쉬워지고, 코드의 가독성이 좋아지며, 개발자의 생산성이 향상된다.

strictNullChecks

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

// tsConfig: {"noImplicitAny":true,"strictNullChecks":false}
const x: number = null;  // OK, null is a valid number

// tsConfig: {"noImplicitAny":true,"strictNullChecks":true}
const x: number = null;
//    ~ Type 'null' is not assignable to type 'number'

noImplictAny설정때와 마찬가지로, null이나 undefined를 사용하고 싶다면 의도적으로 타입에 명시하면 사용할 수 있다.

const x: number | null = null;

만약 null을 허용하지 않으면 이 값이 어디서부터 왔는지 찾아야 하고, null을 체크하는 코드나 타입 단언문(assertion, !)을 추가해야 한다.

// tsConfig: {"noImplicitAny":true,"strictNullChecks":true}
   const el = document.getElementById('status');
   el.textContent = 'Ready';
// ~~ Object is possibly 'null'

   if (el) {
     el.textContent = 'Ready';  // OK, null has been excluded
   }
   el!.textContent = 'Ready';  // OK, we've asserted that el is non-null

다른 설정들도 많지만, 이 두 설정이 제일 중요하다고 보이고 모든 체크를 설정하고 싶다면 strict설정을 하면 된다.

ITEM 03 코드 생성과 타입이 관계없음을 이해하기

타입 스크립트 컴파일러는 두가지 역할을 수행하는데, 이 두 가지가 서로 완벽히 독립적이다.

  • 최신 타입스크립트 / 자바스크립트를 브라우저에 동작할 수 있도록 구버전의 자바스크립트로 트랜스파일(transpile)

    번역(translate)과 컴파일(compile)이 합쳐져 트랜스 파일이라는 신조어가 탄생했다. 소스코드를 동일한 동작을 하는 다른 형태의 소스코드(다른 버전, 다른 언어 등)로 변환하는 행위를 의미한다.

  • 코드의 타입 오류 체크

타입 오류가 있는 코드도 컴파일 ⭕️

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

$ cat test.ts
let x = 'hello';
x = 1234;
$ tsc test.ts
test.ts:2:1 - error TS2322: '1234'형식은 'string'형식에 할당할 수 없습니다.

$ cat test.js 
var x = 'hello';
x = 1234;

타입 오류가 있는데도 컴파일이 된다는 사실 때문에 타입스크립트가 엉성한 언어로 보일 수 있지만, 컴파일된 산출물이 나오는 것이 실제로 도움이 된다. 만일, 웹 애플리케이션을 만들면서 어떤 부분에 문제가 발생했다고 가정해보자. 타입스크립트는 여전히 컴파일된 산출물을 생성하기 때문에, 문제가 된 오류를 수정하지 않더라도 웹 애플리케이션의 다른 부분을 테스트할 수 있다.

만약 오류가 있을 때 컴파일하지 않으려면 tsconfig.jsonnoEmitOnError 설정하거나 빌드 도구에 동일하게 적용하면 된다.

컴파일과 타입 체크
코드에 오류가 있을 때 "컴파일에 문제가 있다"고 말하는 경우가 있다. 이는 기술적으로는 틀린 말이다. 엄밀히 말하면 오직 코드 생성만이 '컴파일'이라고 할 수 있기 때문이다. 작성한 타입스크립트가 유효한 자바스크립트라면 타입스크립트 컴파일러는 컴파일을 해낸다. 그러므로 코드에 오류가 있을 때 "타입체크에 문제가 있다"고 말하는 것이 더 정확한 표현이다.

런타임에는 타입 체크가 불가능 ⛔️

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

function calculateArea(shape: Shape) {
  if (shape instanceof Rectangle) {
                    // ~~~~~~~~~ 'Rectangle' only refers to a type,
                    //           but is being used as a value here
    return shape.width * shape.height;
                    //         ~~~~~~ Property 'height' does not exist
                    //                on type 'Shape'
  } else {
    return shape.width * shape.width;
  }
}

instanceof체크는 런타임에 일어나지만, Rectangle은 타입이기 때문에 런타임 시점에서 제거되어 있어 아무런 역할을 할 수 없다. 자바스크립트로 컴파일 되는 과정에서 모든 인터페이스, 타입, 타입구문은 제거되기 때문이다.

런타임 때 타입정보를 유지하려면, 타입에 선언한 속성을 가지고 있는지 체크해보는 것이다.

interface Square {
  width: number;
}
interface Rectangle extends Square {
  height: number;
}
type Shape = Square | Rectangle;
function calculateArea(shape: Shape) {
  if ('height' in shape) {
    shape;  // Type is Rectangle
    return shape.width * shape.height;
  } else {
    shape;  // Type is Square
    return shape.width * shape.width;
  }
}

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

또는, 타입정보를 유지하는 '태그'기법을 사용할 수 있다(속성중 하나에 이름을 붙인다).

interface Square {
  kind: 'square';
  width: number;
}
interface Rectangle {
  kind: 'rectangle';
  height: number;
  width: number;
}
type Shape = Square | Rectangle;

function calculateArea(shape: Shape) {
  if (shape.kind === 'rectangle') {
    shape;  // Type is Rectangle
    return shape.width * shape.height;
  } else {
    shape;  // Type is Square
    return shape.width * shape.width;
  }
}

또는 타입을 클래스로 만들면 타입과 값을 둘 다 사용할 수 있게 된다.

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;  // Type is Rectangle
    return shape.width * shape.height;
  } else {
    shape;  // Type is Square
    return shape.width * shape.width;  // OK
  }
}

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

타입을 정제하는 코드에서 잘못된 방법을 사용하면 타입체커를 통과해버려서 런타임 동작에 아무런 영향을 미치지 않는다.

// in TS
function asNumber(val: number | string): number {
  return val as number;
}

// in JS - transpile
function asNumber(val) {
  return val;
}

값을 의도한대로 정제하고 싶다면 런타임의 타입을 체크해야 하고, 자바스크립트 연산을 통해 변환을 수행해야 한다.

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

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

타입을 지정했더라도, 실제 런타임 시점에 API의 데이터가 달라졌다거나 잘못파악하는 등의 이유로 실제로 타입이 변경되어서 올 수 있다. 선언된 타입이 언제든지 달라질 수 있다는 점을 명심해야 한다.

function turnLightOn() {}
function turnLightOff() {}
function setLightSwitch(value: boolean) {
  switch (value) {
    case true:
      turnLightOn();
      break;
    case false:
      turnLightOff();
      break;
    default:
      console.log(`I'm afraid I can't do that.`);
  }
}
interface LightApiResponse {
  lightSwitchValue: boolean;
}
async function setLight() {
  const response = await fetch('/light');
  const result: LightApiResponse = await response.json();
  setLightSwitch(result.lightSwitchValue);
}

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

타입스크립트에서는 타입과 런타임의 동작이 무관하기 때문에, 함수 오버로딩이 불가하다.

function add(a: number, b: number) { return a + b; }
      // ~~~ Duplicate function implementation
function add(a: string, b: string) { return a + b; }
      // ~~~ Duplicate function implementation

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

// tsConfig: {"noImplicitAny":false}
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);  // Type is number
const twelve = add('1', '2');  // Type is string

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

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

  • 런타임 오버헤드가 없는 대신, 타입스크립트 컴파일러는 빌드타임 오버헤드가 있다.
  • 타입스크립트가 컴파일하는 코드는 오래된 런타임 환경을 지원하기 위해 호환성을 높이고 성능 오버헤드를 감안할지, 호환성을 포기하고 성능 중심의 네이티브 구현체를 선택할지의 문제를 맞닥뜨릴 수 있다. 어떤 경우든지 호환성과 성능 사이의 선택은 컴파일 타깃과 언어 레벨의 문제이며 여전히 타입과는 무관하다.

ITEM 04 구조적 타이핑에 익숙해지기

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

이러한 특성으로 구조적 타이핑(structural typing)이 발생할 수 있다.

interface Vector2D {
  x: number;
  y: number;
}
function calculateLength(v: Vector2D) {
  return Math.sqrt(v.x * v.x + v.y * v.y);
}
interface NamedVector {
  name: string;
  x: number;
  y: number;
}
const v: NamedVector = { x: 3, y: 4, name: 'Zee' };
calculateLength(v);  // OK, result is 5

calculateLength 함수에 인자값이 Vector2D 타입이 아닌 NamedVector타입이 들어갔어도 에러가 발생하지 않는 이유는 자바스크립트의 런타임 동작을 모델링하기 때문이다. NamedVector구조가 Vector2D와 호환되기 때문에 호출이 가능하다.

이 때문에 문제가 발생할 수도 있다.

interface Vector2D {
  x: number;
  y: number;
}
function calculateLength(v: Vector2D) {
  return Math.sqrt(v.x * v.x + v.y * v.y);
}
interface NamedVector {
  name: string;
  x: number;
  y: number;
}
interface Vector3D {
  x: number;
  y: number;
  z: number;
}
function normalize(v: Vector3D) {
  const length = calculateLength(v);
  return {
    x: v.x / length,
    y: v.y / length,
    z: v.z / length,
  };
}

normalize({x: 3, y: 4, z: 5}) // { x: 0.6, y:0.8, z: 1}

위와 같은 구조적 타이핑 관점에서 Vector3D타입이 x, y를 가지고 있기 때문에 Vector2D와 호환이 되어 타입문제는 발생하지않고, 의도한 바와 다르게 계산이 된다.
함수를 작성할 때 호출에 사용되는 매개변수의 속성들이 배개변수의 타입에 선언된 속성만을 가질거라 생각(sealed, precise)하기 쉽다. 그러나 타입스크립트의 타입 시스템은 열려(open)있다.

테스트를 작성할 때는 구조적 타이핑이 유리하다. 라이브러리간의 의존성을 완벽히 분리할 수 있는데, 이 내용은 아이템 51에서 나올 예정이다.

ITEM 05 any타입 지양하기

any 타입에는 타입 안전성이 없다.

any타입을 사용하면 number타입이던 변수에 string타입을 할당할 수 있게 되어 혼돈이 발생한다.

any는 함수 시그니처를 무시해버린다.

함수를 작성할 때는 시그니처를 명시해야 한다. 호출하는 쪽은 약속된 타입의 입력을 제공하고, 함수는 약속된 타입의 출력을 반환한다. 그러나 any타입을 사용하면 이런 약속을 어길 수 있다.

   let age: number;
   age = '12';
// ~~~ Type '"12"' is not assignable to type 'number'
   age = '12' as any;  // OK
function calculateAge(birthDate: Date): number {
  // COMPRESS
  return 0;
  // END
}

let birthDate: any = '1990-01-19';
calculateAge(birthDate);  // OK

any타입에는 언어 서비스가 적용되지 않는다.

자동완성 기능과 적절한 도움말(마우스 커서를 올리면 타입이 보이는 등)을 받을 수 없다.
전체 이름을 변경할 때도 도움을 받을 수 없다.

any 타입은 코드 리팩터링 때 버그를 감춘다 😶‍🌫️

interface ComponentProps {
  onSelectItem: (item: any) => void;
}
function renderSelector(props: ComponentProps) { /* ... */ }

let selectedId: number = 0;
function handleSelectItem(item: any) {
  selectedId = item.id;
}

renderSelector({onSelectItem: handleSelectItem});

위와 같은 코드가 있을 때, onSelectItem에 필요한 아이템만 전달하도록 ComponentProps를 아래처럼 개선을 한다고 가정하자.

interface ComponentProps {
  onSelectItem: (id: number) => void;
}

handleSelectItem에 들어오는 itemany타입이므로 id가 없더라도 타입체커는 통과시키지만 런타임에서는 오류가 발생할 수 있다.

any는 타입 설계를 감춰버린다.

any는 타입시스템의 신뢰도를 떨어뜨린다.

미리 오류를 잡기 위해 사용하는 타입체커를 더이상 신뢰할 수 없게 된다. 타입 오류를 고쳐야하고, 머릿속에 실제 타입을 계속 기억해둬야 할 수 있다.

profile
고양이 두마리를 모시고 있는 프론트엔드 코더(?)

4개의 댓글

comment-user-thumbnail
2023년 1월 7일

빨리(??) 다 정리해주세요…헤헤

1개의 답글
comment-user-thumbnail
2023년 1월 14일

우와 잘 읽고 갑니다 👍👍

1개의 답글