Effective TypeScript 학습 정리

Effective TypeScript 학습한 내용을 알아보자.

✅ 1장 TS 알아보기

아이템 1: TS와 JS의 관계

“타입스크립트는 자바스크립트의 상위집합(superset)이다”

  1. 그렇기 때문에 JS 코드는 이미 TS다.

    • 기존 JS 코드를 타입스크립트로 마이그레이션하는데 큰 이점
    • 타입 구문을 사용하는 순간부터 JS는 TS 영역으로 들어가게 됨
  2. 타입 시스템에서는 런타임에 오류를 발생시킬 코드를 미리 찾아낸다.

    const city = [
      { name: 'jeon-ju', food: 'kong-na-mul-kook-bob' },
      { name: 'seoul', food: 'none' },
      // ...
    ];
     
    for (const localCity of city) {
      console.log(localCity.size); // JS에서는 undefined, TS에서는 에러
    }
  3. 타입을 명시적으로 선언하여 의도를 분명하게 하면 오류를 구체적으로 알 수 있다.

    interface City {
      name: string;
      food: string;
    }
     
    const city: City[] = [
      { name: 'jeon-ju', food: 'kong-na-mul-kook-bob' },
      { name: 'seoul', food: 'none' },
    ];
     
    // 🚨 Error
    // 'City' 형식에 'size'이 없습니다.
    for (const localCity of city) {
      console.log(state.size);
    }

아이템 2: 타입스크립트 설정

  1. tsconfig.json으로 타입스크립트 설정 작성

    {
      "compilerOptions": {
        // ...
      }
    }
  2. noImplicitAny : 변수들이 미리 정의된 타입을 가져야 하는지 여부를 제어(any 타입을 사용하면 에러 설정)

    function add(a, b) {
      return a + b;
    }
     
    // 이를 암시적 any라고 부른다
    // noImplicitAny가 설정되었다면 오류 발생
    function add(a: any, b: any): any;
  3. strictNullChecks: nullundefined가 모든 타입에서 허용되는지 설정

    // strictNullChecks 해제 시
    const x: number = null; // 정상
     
    // strictNullChecks 설정 시
    // 🚨 에러: 'null' 형식은 'number' 형식에 할당할 수 없습니다.
    const x: number = null;

아이템 3: 코드 생성과 타입은 관계가 없음

  1. TS 컴파일러는 2가지 역할을 수행

    • 최신 TS,JS를 브라우저에서 동작할 수 있도록 구버전 JS로 트랜스파일 함
    • 코드의 타입 오류를 체크 함
  2. 타입 오류가 있는 코드도 컴파일 가능

    • 컴파일은 타입 체크와 독립적으로 동작하기 때문
    • 작성한 TS가 유효한 JS라면 TS 컴파일러는 컴파일 진행
  3. 런타임에는 타입 체크가 불가능

    • TS의 타입은 ‘제거 가능’ 즉, JS로 컴파일되는 과정에서 모든 인터페이스, 타입, 타입 구문은 그냥 사라짐

    • 런타임에 타입 정보를 유지하는 방법

      • 특정 속성이 존재하는지 체크

      • 태그기법 : 런타임에 접근 가능한 타입 정보를 명시적으로 저장

        interface Square {
          kind: 'square';
          width: number;
        }
         
        interface Rectangle {
          kind: 'rectangle';
          height: number;
          width: number;
        }
         
        // '태그된 유니온(tagged union)'
        type Shape = Square | Rectangle;
         
        function calculateArea(shape: Shape) {
          if (shape.kind === 'rectangle') {
            shape; // 타입이 Rectangle
            return shape.width * shape.height;
          } else {
            shape; // 타입이 Square
            return shape.width * shape.width;
          }
        }
  4. 타입 연산은 런타임에 영향을 주지 않음

    • 값을 정제하기 위해서는 런타임의 타입을 체크해야 하고 JS 연산을 통해 변환을 수행해야 함
  5. 런타임 타입은 선언된 타입과 다를 수 있음

    • switch~case 구문의 default 구문
    • API 요청의 반환값을 사용하는 경우
  6. TS 타입으로는 함수를 오버로드할 수 없음

    function add(a: number, b: number) {
      return a + b;
    } // 🚨 에러: 중복된 함수 구현입니다.
     
    function add(a: string, b: string) {
      return a + b;
    } // 🚨 에러: 중복된 함수 구현입니다.
    • TS 함수 오버로딩은 타입 수준에서만 가능(구현체는 불가)
      function add(a: number, b: number): number;
      function add(a: string, b: string): string;
  7. TS 타입은 런타임 성능에 영향을 주지 않음

    • 타입과 타입 연산자는 JS 변환 시점에 제거되기 때문
    • 런타임 오베허드가 없는 대신, TS 컴파일러는 ‘빌드타임’ 오버헤드가 있음
    • TS 컴파일하는 코드는 오래된 런타임 환경을 지원하기 위해 호환성을 높이고 성능 오버헤드를 감안할지, 호환성을 포기하고 성능 중심의 네이티브 구현체를 선택할지의 문제에 맞닥뜨릴 수도 있음

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

  1. JS는 본질적으로 덕 타이핑(duck typing) 기반

    • 덕 타이핑 : 객체가 어떤 타입에 부합하는 변수와 메서드를 가질 경우 객체를 해당 타입에 속하는 것으로 간주하는 방식
  2. TS도 JS 처럼 덕 타이핑 동작을 그대로 모델링 함

    interface Vector2D {
      x: number;
      y: number;
    }
     
    function calculateLength(v: Vector2D) {
      return Math.sqrt(v.x * v.x + v.y * v.y);
    }
     
    // Vector2D 인터페이스를 확장하며, name 프로퍼티를 추가로 가지고 있음
    // 구조적 타이핑 때문에, Vector2D 인터페이스와 호환
    // NamedVector 인터페이스의 x,y와 Vector2D의 x와 y 동일
    // 따라서 NamedVector 인터페이스를 구현한 객체도
    // calculateLength 함수의 인자로 사용할 수 있음
    interface NamedVector {
      name: string;
      x: number;
      y: number;
    }
     
    let v: NamedVector = { name: 'vec1', x: 3, y: 4 };
    calculateLength(v); // 5
  3. 구조적 타이핑을 사용하면 유닛 테스트를 쉽게 할 수 있음

    • TS는 테스트 DB가 특정 인터페이스를 충족하는지 확인

    • 추상화(DB)를 함으로써, 로직과 테스트를 특정한 구현으로부터 분리 가능

      interface DB {
        runQuery: (sql: string) => any[];
      }
       
      function getAuthors(database: DB): Author[] {
        const authorRows = database.runQuery(`SELECT FIRST, LAST FROM AUTHORS`);
        return authorRows.map((row) => ({ first: row[0], last: row[1] }));
      }

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

  1. any 타입에는 타입 안정성이 없음

    let age: number;
    age = '12' as any; // 정상
    age += 1; // 런타임에 정상, 🚨 age는 '121'
  2. any는 함수 시그니처를 무시 함

    function calculateAge(birthDate: Date): number {
      // ...
    }
     
    let birthDate: any = '1592-05-11';
    calculateAge(birthDate); // 정상 (🚨 추후 에러 발생 가능)
  3. any 타입에는 언어 서비스가 적용되지 않음

    • IDE의 자동완성 기능과 적절한 도움말 제공 불가
  4. any 타입은 코드 리팩터링 때 버그를 감춤

    • any가 아닌 구체적인 타입을 사용하여 타입 체커가 오류를 발견하도록 해야 함
  5. any는 타입 설계를 감춤

    • 애플리케이션 상태 등의 객체 설계 시 any 사용을 지양
  6. any는 타입시스템의 신뢰도를 떨어뜨림

    • 사람은 항상 실수를 함
    • any 타입을 쓰지 않으면 런타임에 발견될 오류를 미리 잡을 수 있고 신뢰도를 높일 수 있음


✅ 2장 TS 타입 시스템

아이템 6: 편집기를 사용하여 타입 시스템 탐색하기

  • TS에서 실행할 수 있는 프로그램
    • TS 컴파일러(tsc)
    • 단독 실행 가능한 TS 서버
  • TS 서버에서 제공하는 언어 서비스를 사용 권장
  • 많은 편집기에서 TS가 그 타입을 어떻게 판단하는지 확인 가능
  • 편집기 타입 오류를 살펴보는 것도 타입 시스템을 파악하는 데 좋은 방법
  • 라이브러리와 라이브러리의 타입 선언
    • Go to Definition 옵션으로 d.ts에서 타입 정의 확인 가능

아이템 7: 타입이 값들의 집합이라고 생각하기

  1. 런타임시 모든 변수는 JS로 정해진 고유한 값 존재

  2. 코드가 실행되기 전 TS가 오류를 체크하는 순간에는 타입을 가지고 있으며, 이는 할당 가능한 값들의 집합

  3. 집합의 종류

    • never : 아무것도 포함하지 않는 공집합(아무 값도 할당 불가) cf) voidundefined 반환
      // 🚨 '12' 형식은 'never' 형식에 할당할 수 없습니다.
      const x: never = 12;
    • 리터럴(유닛)타입 : 한 가지 값만 포함하는 타입
      type A = 'A';
    • 유니온 타입 : 두 개 혹은 세 개 값 포함하는 집합들의 합집합
      type AB = 'A' | 'B';
  4. 할당 가능하다는 뜻 -> 부분 집합

    // 'A'는 집합 {'A', 'B'}의 원소
    const a: AB = 'A';
  5. 실제 다루게 되는 타입은 대부분 범위가 무한대

    type Int = 1 | 2 | 3 | 4 | 5; // | ...
  6. 원소를 서술하는 방법

    interface Identified {
      id: string;
    }
  7. 타입(값의 집합)

    • & 연산자는 두 타입의 인터섹션(교집합)을 계산

    • | 연산자는 두 인터페이스의 유니온, 교집합이 없는 두 개 이상의 타입에서 사용 시 주의

      interface Person {
        name: string;
      }
      interface Lifespan {
        birth: Date;
        death?: Date;
      }
       
      type PersonSpan = Person & Lifespan;
      type K = keyof (Person | Lifespan); // 타입이 never
  8. extends : ~에 할당 가능한, ~의 부분집합

    • 서브타입 : 어떤 집합이 다른 집합의 부분집합
    interface Vector1D {
      x: number;
    }
     
    // Vector2D는 Vector1D의 서브타입
    interface Vector2D extends Vector1D {
      y: number;
    }
     
    // Vector3D는 Vector2D의 서브타입
    interface Vector3D extends Vector2D {
      z: number;
    }
  9. 제네릭에서 extends

    // K는 집합 관점에서 string을 상속
    // string 리터럴 타입, string 리터럴 타입의 유니온, string 자신을 포함
    function getKey<K extends string>(val: string, key: K) {
      // ...
    }
  10. 타입이 집합이라는 관점에서 배열과 튜플의 관계 확인

    // 타입은 number[]
    const list = [1, 2];
     
    // 🚨 'number[]' 타입이 '[number, number]'타입 보다 큰 집합이여서
    // 에러 발생
    // Target requires 2 element(s) but source may have fewer
    const tuple: [number, number] = list;
  11. 트리플

    const triple: [number, number, number] = [1, 2, 3];
     
    // 🚨 숫자의 length값이 맞지 않기 때문에 할당문에 오류 발생
    const double: [number, number] = triple;
  12. 타입이 값의 집합이라는 뜻은, 동일한 값의 집합을 가지는 두 타입은 같다는 의미


아이템 8: 타입 공간과 값 공간의 심벌 구분하기

  1. TS 심벌(symbol)은 타입 공간이나 값 공간 중 한 곳에 존재

    interface Cylinder {
      radius: number;
      height: number;
    }
     
    const Cylinder = (radius: number, height: number) => ({ radius, height });
    • interface Cylinder는 타입, const Cylinder는 변수
    • 일반적으로 type이나 interface 다음에 나오는 심벌은 타입, const나 let 선언에 쓰이는 것은 값
  2. classenum 은 상황에 따라 타입과 값 두 가지 모두 가능

    • 클래스가 타입으로 쓰일 때는 형태(속성과 메서드)가 사용되는 반면, 값으로 쓰일 때는 생성자가 사용됨

      // 타입으로 쓰인 Cylinder 클래스
      class Cylinder {
        radius = 1;
        height = 1;
      }
       
      function calculateVolume(shape: unknown) {
        if (shape instanceof Cylinder) {
          shape; // 정상, 타입은 Cylinder
          shape.radius; // 정상, 타입은 number
        }
      }
  3. typeof : 타입에서 쓰일 때와 값에서 쓰일 때가 다름

    • 타입의 관점에서 typeof는 값을 읽어서 TS 타입을 반환

    • 값의 관점에서 typeof는 JS 런타임의 typeof 연산자를 반환(심벌의 런타임 타입을 가리킴)

      // 타입은 Person
      type T1 = typeof p;
       
      // 타입은 (p: Person, subject: string, body: string) => Response
      type T2 = typeof email;
       
      // 값은 'object'
      const v1 = typeof p;
       
      // 값은 'function'
      const v2 = typeof email;
  4. 클래스

    // 타입이 typeof Cylinder
    type T = typeof Cylinder;
     
    declare let fn: T;
    const c = new fn(); // 타입이 Cylinder
    • InstanceType 제너릭을 사용해 생성자 타입과 인스턴스 타입 전환 가능
      type C = InstanceType<typeof Cylinder>; // 타입이 Cylinder
  5. 속성 접근자 []

    • obj['field']obj.field는 값이 동일하더라도 타입은 다를 수 있으므로, 타입의 속성을 얻을 때는 obj['field']를 지향

아이템 9: 타입 단언보다는 타입 선언을 사용하기

  1. 타입 단언은 오류를 발견하지 못 함

    interface Person {
      name: string;
    }
     
    // 🚨 'Person' 유형에 필요한 'name' 속성이 '{}' 유형에 없습니다.
    const kay: Person = {};
    const bob = {} as Person; // 오류 없음
    • 속성을 추가할 때도 마찬가지(타입 선언문에서는 잉여 속성 체크가 동작하지만, 단언문에서는 적용되지 않음)
  2. 화살표 함수의 타입 선언

    const people = ['kay', 'bob', 'jun'].map((name) => ({ name }));
     
    // Person[]을 원했지만 결과는 { name: string; }[]...
    • 단언문 대신 화살표 함수의 반환 타입을 선언
      // 타입은 Person[]
      const people = ['kay', 'bob', 'jun'].map((name): Person => ({ name }));
      • 그러나 함수 호출 체이닝이 연속되는 곳에서는 체이닝 시작에서부터 명명된 타입을 가져야 오류가 정확하게 표시 됨
  3. 타입 단언이 꼭 필요한 경우

    • 타입 체커가 추론한 타입보다 개발자가 판단하는 타입이 더 정확할 때
    document.querySelector('#myButton').addEventListener('click', (e) => {
      e.currentTarget; // 타입은 EventTarget
     
      // 타입은 HTMLButtonElement
      const button = e.currentTarget as HTMLButtonElement;
    });
  4. ! 문법을 사용해서 null이 아님을 단언하는 경우

    // 타입은 HTMLElement | null
    const elNull = document.getElementById('foo');
     
    // 타입은 HTMLElement
    const el = document.getElementById('foo')!;
  5. 타입 단언문으로 임의의 타입 간에 변환

    • AB의 부분집합(서브타입)인 경우 사용

아이템 10 : 객체 래퍼 타입 피하기

  1. JS는 기본형과 객체 타입을 서로 자유롭게 변환 가능(래퍼 객체)
  2. string 기본형과 String 래퍼 객체가 항상 동일하게 동작하는 것은 아님
    • String 객체는 오직 자기 자신하고만 동일하다
      'hello' === new String('hello'); // false
      new String('hello') === new String('hello'); // false
  3. TS는 기본형과 객체 래퍼 타입을 별도로 모델링 함
    // 🚨 'String' 형식의 인수는 'string' 형식의 매개변수에 할당될 수 없습니다.
    // 'String'은 Object 임
    function isGreeting(phrase: String) {
      return ['hello', 'good day'].includes(phrase);
    }
    • stringString 래퍼 객체에 할당할 수 있지만, String 래퍼 객체string에 할당할 수 없음
    • TS가 제공하는 타입 선언은 전부 기본형 타입

아이템 11: 잉여 속성 체크의 한계 인지하기

  1. 타입이 명시된 변수에 객체 리터럴을 할당할 때 TS는 해당 타입의 속성이 있는지, 그리고 그 외의 속성은 없는지 확인

    interface Room {
      numb: number;
      size: number;
    }
     
    const room = {
      numb: 1,
      size: 10,
      bed: 4,
    };
     
    const secondRoom: Room = room; // 정상
    • room 타입은 Room 타입의 부분 집합을 포함하므로, Room에 할당 가능하며 타입 체커도 통과 함
    • 잉여 속성 체크는 할당 가능 검사와는 별도의 과정
  2. TS는 런타임 오류 뿐 아니라, 의도와 다르게 작성된 코드까지 찾음

    interface Options {
      title: string;
      darkMode?: boolean;
    }
     
    function createWindow(options: Options) {
      if (options.darkMode) {
        setDarkMode();
      }
    }
     
    createWindow({
      title: 'Spider Solitaire',
      darkmode: true, // 🚨 에러 darkMode 아님?
    });
    • 런타임에 에러가 발생하지 않지만, TS에서 에러가 발생 함
  3. Options는 넓은 타입으로 해석 됨

    interface Options {
      title: string;
      darkMode?: boolean;
    }
     
    const o1: Options = document; // 정상
    const o2: Options = new HTMLAnchorElement(); // 정상
    • documentHTMLAnchorElement의 인스턴스 모두 string 타입인 title 속성을 갖고 있기 때문에 할당문 정상
  4. 잉여 속성 체크는 객체 리터럴만 체크 함

    interface Options {
      title: string;
      darkMode?: boolean;
    }
     
    // 🚨 에러 - darkMode 아님?
    const o1: Options = { darkmode: true, title: 'Ski Free' };
     
    const intermediate = { darkmode: true, title: 'Ski Free' };
    const o2: Options = intermediate; // 정상
     
    // 타입 단언문을 사용할 때도 적용되지 않는다
    const o3: Options = { darkmode: true, title: 'Ski Free' } as Options; // 정상

아이템 12: 함수 표현식에 타입 적용하기

  1. TS는 함수 선언문이 아닌, 함수 표현식을 권장

    • 함수의 매개변수부터 반환값까지 전체를 함수 타입으로 선언하여 함수 표현식에 재사용할 수 있다는 장점 존재(시그니처)
      type DiceRollFn = (sides: number) => number;
      const rollDice: DiceRollFn = (sides) => {};
  2. 반복되는 함수 시그니처를 하나의 함수로 통합하여 불필요한 코드의 반복을 줄일 수 있음

    • 라이브러리는 공통 함수 시그니처를 타입으로 제공 ex) 리액트 MouseEventHandler
  3. 시그니처가 일치하는 다른 함수가 있을 때도 함수 표현식에 타입 적용 가능

    • ex) fetch 함수

      // 타입이 Promise<Response>
      const responsePromise = fetch('/search?by=Kay');
    • 응답의 데이터를 추출

      async function getSearch() {
        const response = await fetch('/search?by=Kay');
        const data = await response.json();
       
        return data;
      }
      • 이때 /search가 존재하지 않는 API거나 fetch가 실패하는 경우 버그가 발생 함
      • 상태 체크를 수행해 줄 checkedFetch 함수 작성
      • 함수 선언문을 함수 표현식으로 바꾸고, 함수 전체에 타입을 적용
      // lib.dom.d.ts
      declare function fetch(
        input: RequestInfo,
        init?: RequestInit
      ): Promise<Response>;
       
      const checkedFetch: typeof fetch = async (input, init) => {
        const response = await fetch(input, init);
       
        if (!response.ok) {
          throw new Error('Request failed: ' + response.status);
        }
        return response;
      };
      ``;

아이템 13: 타입과 인터페이스의 차이점 알기

  1. 인터페이스와 타입 모두 사용 가능한 경우

    • 인덱스 시그니처
    • 함수 타입
    • 제너릭
       type TPair<T> = {
           first: T;
           second: T;
       }
     
       interface IPair<T> = {
           first: T;
           second: T;
       }
  2. 인터페이스는 다른 타입을 포함할 수 있어 타입을 확장 할 수 있고 타입이 인터페이스를 포함 시킬 경우 인터페이스를 확장 할 수 있음

    • 인터페이스가 타입을 확장하는 경우

      interface Person {
        name: string;
        age: number;
      }
       
      interface Employee extends Person {
        salary: number;
      }
    • 타입이 인터페이스를 확장하는 경우

      interface Shape {
        color: string;
        area(): number;
      }
       
      type Circle = {
        radius: number;
      } & Shape;
  3. 인터페이스와 타입의 차이점

    • 인터페이스는 객체의 구조를 정의하기 위한 것으로 사용
    • 타입은 객체, 변수, 함수 등의 값을 설명하기 위해 사용
    • 유니온 타입은 있지만 유니온 인터페이스는 없음
    type AorB = 'a' | 'b';
    • 유니온 타입 확장이 필요한 경우

      type Input = {
        /* ... */
      };
       
      type Output = {
        /* ... */
      };
       
      interface VariableMap {
        [name: string]: Input | Output;
      }
    • 유니온 타입에 추가 속성을 붙인 타입 만들기(인터페이스로 표현 불가)

      type NamedVariable = (Input | Output) & { name: string };
    • 튜플과 배열 타입

      type Pair = [number, number];
      type StringList = string[];
      type NamedNumbs = [string, ...number[]];
       
      // 인터페이스로 튜블과 비슷하게 구현(제한적, 튜플 메서드 사용 불가)
      interface Tuple {
        0: number;
        1: number;
        length: 2;
      }
      const t: Tuple = [10, 20]; // 정상
    • 타입에는 없는 인터페이스의 보강 기능(선언 병합)

       interface IState {
         name: string;
         capital: string;
       }
       
       interface IState {
         population: number;
       }
       
       const city: IState = {
         name: "Jeon-Ju",
         capital: "Jeon-Ju",
         population: 500,000,
       }; // 정상
    • TS는 여러 버전의 JS 표준 라이브러리에서 타입을 모아 병합 함

    • 타입은 기존 타입에 추가적인 보강이 없는 경우에만 사용해야 함

  4. 복잡한 타입이라면 타입 별칭을, 간단한 객체 타입이라면 인터페이스를 사용(협업시 일관성 있게 사용하는 것이 중요)


아이템 14: 타입 연산과 제너릭 사용으로 반복 줄이기

  1. 타입에 이름 붙이기

    • 타입이 반복적으로 등장하는 함수

      function distance(
        a: { x: number; y: number },
        b: { x: number; y: number }
      ) {
        return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2));
      }
    • 타입에 이름을 붙여 개선하기

      interface Point2D {
        x: number;
        y: number;
      }
       
      function distance(a: Point2D, b: Point2D) {
        /* ... */
      }
  2. 함수의 타입 시그니처 개선하기

    • 몇몇 함수가 같은 타입 시그니처를 공유하는 경우

      function get(url: string, opts: Options): Promise<Response> {
        /* ... */
      }
       
      function post(url: string, opts: Options): Promise<Response> {
        /* ... */
      }
    • 해당 시그니처를 명명된 타입으로 분리하기

       type HTTPFunction = (url: string, opts: Options) => Promise<Response>;
       
       function get: HTTPFunction = (url, opts) => { /* ... */ }
       function post: HTTPFunction = (url, opts) => { /* ... */ }
  3. 인터페이스를 확장하여 반복 제거하기

    interface Person {
      firstName: string;
      lastName: string;
    }
     
    interface PersonWithBirthDate extends Person {
      birth: Date;
    }
  4. 이미 존재하는 타입을 확장하는 경우 인터섹션 연산자(&) 사용하기

    type PersonWithBirthDate = Person & { birth: Date };
  5. 전체 애플리케이션의 상태를 표현하는 State 타입과 부분만 표현하는 TopNavState 합치기

    interface State {
      userId: string;
      pageTitle: string;
      recentFiles: string[];
      pageContents: string;
    }
     
    interface TopNavState {
      userId: string;
      pageTitle: string;
      recentFiles: string[];
    }
    • 매핑된 타입 사용하기

      type TopNavState = {
        [k in 'userId' | 'pageTitle' | 'recentFiles']: State[k];
      };
    • 유틸 타입 Pick 사용하기

      type TopNavState = Pick<State, 'userId' | 'pageTitle' | 'recentFiles'>;
  6. 태그된 유니온에서 인덱싱하기

    interface SaveAction {
      type: 'save';
    }
     
    interface LoadAction {
      type: 'load';
    }
     
    type Action = SaveAction | LoadAction;
    type ActionType = Action['type']; // 타입은 'save' | 'load'
  7. 타입을 선택적 필드를 포함하는 타입으로 변환하기

    interface Options {
      width: number;
      height: number;
      color: string;
      label: string;
    }
     
    interface OptionsUpdate {
      width?: number;
      height?: number;
      color?: string;
      label?: string;
    }
    • 매핑된 타입과 keyof 사용하기

      type OptionsUpdate = { [k in keyof Options]?: Options[k] };
    • 유틸 타입 Partial 사용하기

      type OptionsUpdate = Partial<Options>;
  8. 값의 형태를 타입의 형태로 전환하는 방법

    const INIT_OPTIONS = {
      width: 640,
      height: 480,
      color: '#00FF00',
      label: 'VGA',
    };
     
    type Options = typeof INIT_OPTIONS;
  9. 함수나 메서드의 반환 값에 명명된 타입 만들기

    function getUserInfo(userId: string) {
      // ...
      return { userId, name, age, height, weight, favoriteColor };
    }
    // 추론된 반환 타입은 { userId: string; name: string; age: number, ... };
    • ReturnType 제네릭 사용하기
      type UserInfo = ReturnType<typeof getUserInfo>;
  10. 제너릭 타입에서 매개변수 제한하기

    interface Name {
      first: string;
      last: string;
    }
     
    type DancingDuo<T extends Name> = [T, T];
     
    const couple1: DancingDuo<{ first: string }> = [
      { first: 'Kay' },
      { first: 'Su' },
    ]; // 🚨 에러
     
    // extends를 사용하여 Pick의 정의 완성하기
    type Pick<T, K extends keyof T> = {
      [k in K]: T[k];
    };
     
    type FirstLast = Pick<Name, 'first' | 'last'>; // 정상
    type FirstMiddle = Pick<Name, 'first' | 'middle'>; // 🚨 에러

아이템 15: 동적 데이터에 인덱스 시그니처 사용하기

  1. TS에서는 타입에 ‘인덱스 시그니처’를 명시하여 유연하게 매핑을 표현

    // 키의 이름(키의 위치만 표시하는 용도), 키의 타입, 값의 타입
    // 🚨 자동완성, 정의로 이동, 이름 바꾸기 등에서 문제 발생
    type Rocket = { [property: string]: string };
    const rocket: Rocket = {
      name: 'Falcon 9',
      variant: 'v1.0',
      thrust: '4,940 kN',
    }; // 정상
  • 인덱스 시그니처는 부정확하므로 인터페이스 사용

    interface Rocket {
      name: string;
      variant: string;
      thrust_kN: number;
    }
  1. 인덱스 시그니처는 동적 데이터를 표현할 때 사용

    • CSV 파일의 데이터 행을 열 이름과 값으로 매핑하는 객체로 나타내고 싶은 경우, 열 이름이 무엇인지 미리 알 방법이 없을 때 사용
    function parseCSV(input: string): { [columnName: string]: string }[] {
      const lines = input.split('\n');
      const [header, ...rows] = lines;
      const headerColumns = header.split(',');
     
      // 연관 배열의 경우, 객체에 인덱스 시그니처를 사용하는 대신, Map 타입을 사용하는 것을 고려
      return rows.map((rowStr) => {
        const row: { [columnName: string]: string } = {};
        rowStr.split(',').forEach((cell, i) => {
          row[headerColumns[i]] = cell;
        });
     
        return row;
      });
    }
  2. 특정 타입에 필드가 제한되어 있는 경우 인덱스 시그니처로 모델링 지양

    interface Row1 {
      [column: string]: number;
    } // 너무 광범위
     
    interface Row2 {
      a: number;
      b?: number;
      c?: number;
      d?: number;
    } // 최선
     
    type Row3 =
      | { a: number }
      | { a: number; b: number }
      | { a: number; b: number; c: number }
      | { a: number; b: number; c: number; d: number }; // 가장 정확하지만 사용하기 번거로움
    • Record 사용
      type Vec3D = Record<'x' | 'y' | 'z', number>;
    • 매핑된 타입 사용(키마다 별도의 타입 사용 가능)
      type Vec3D = { [k in 'x' | 'y' | 'z']: number };
      type ABC = { [k in 'a' | 'b' | 'c']: k extends 'b' ? string : number };

아이템 16: Array, 튜플, ArrayLike를 사용하기

number 인덱스 시그니처보다는 Array, 튜플, ArrayLike를 사용하기

  1. JS 객체의 키는 문자열만 가능

    • 숫자는 키로 사용 불가
    • 배열의 인덱스도 사실은 문자열
  2. TS는 숫자 키를 허용하고, 문자열 키와 다른 것으로 인식

    • Array의 타입 선언(lib.es5.d.ts)
      interface Array<T> {
        [n: number]: T;
      }
  3. 인덱스 시그니처가 number로 표현되어 있다면 입력한 값이 number여야 한다는 것을 의미하지만, 실제 런타임에 사용되는 키는 string 타입

  4. 만약 숫자로 인덱싱을 한다면 Array 또는 튜플 타입을 사용하는 것을 권장

  5. Array의 메서드를 사용하고자 하는 게 아니라면 ArrayLike 타입을 사용

    type ArrayLike<T> = {
      readonly length: number;
      readonly [n: number | string]: T;
    };
     
    function checkedAccess<T>(xs: ArrayLike<T>, i: number): T {
      if (i < xs.length) {
        return xs[i];
      }
      throw new Error('...');
    }
  • ArrayLike는 길이와 인덱스 시그니처만 있음
  • ArrayLike도 키는 숫자 또는 문자열

아이템 17: 변경 오류 방지를 위해 readonly 사용하기

  1. 함수 파라미터로 넘어가는 배열의 변경을 방지

  2. readonly

    • 배열의 요소를 읽을 수 있지만, 쓸 수는 없음
    • length를 읽을 수 있지만, 바꿀 수는 없음
    • 배열을 변경하는 pop을 비롯한 다른 메서드를 호출할 수 없음
  3. number[]readonly number[]의 서브타입

  4. 매개변수를 readonly로 선언하면?

    • TS는 매개변수가 함수 내에서 변경이 일어나는지 체크 함
    • 호출하는 쪽에서는 함수가 매개변수를 변경하지 않는다는 보장을 받게 됨
    • 호출하는 쪽에서 함수에 readonly 배열을 매개변수로 넣을 수도 있음
  5. JS에서는 기본적으로 함수가 매개변수를 변경하지 않는다고 가정하지만, 이러한 암묵적인 방법은 타입 체크에 문제를 일으킬 수 있음

  6. 어떤 함수를 readonly로 만들면, 그 함수를 호출하는 다른 함수들도 모두 readonly로 만들어야 함(타입의 안전성을 높임)

  7. readonly 배열을 조작하는 방법

    • arr.length = 0 대신 arr = []
    • arr.push('abc') 대신 arr = arr.concat(['abc'])
  8. readonly는 얕게(shallow) 동작한다

    • 객체로 구성된 readonly 배열이 있다면, 그 객체 자체는 readonly가 아님

    • 객체에 사용할 때는 Readonly 제네릭을 사용

      interface Outer {
        inner: {
          x: number;
        };
      }
       
      const o: ReadOnly<Outer> = { inner: { x: 0 } };
      o.inner = { x: 1 }; // 🚨 에러
      o.inner.x = 1; // 정상
    • cf) ts-essentialsDeepReadonly 제네릭

    • 인덱스 시그니처에 readonly를 사용하면 객체 속성 변경 방지 가능


아이템 18: 매핑된 타입을 사용하여 값을 동기화하기

  1. 여러번 반복되는 타이핑 줄이기

    interface ScatterProps {
      xs: number[];
      ys: number[];
      xRange: [number, number];
      yRange: [number, number];
      color: string;
      onClick: (x: number, y: number, index: number) => void;
    }
     
    const REQUIRES_UPDATE: { [k in keyof ScatterProps]: boolean } = {
      xs: true,
      ys: true,
      xRange: true,
      yRange: true,
      color: true,
      onClick: false,
    };
     
    function shouldUpdate(oldProps: ScatterProps, newProps: ScatterProps) {
      let k: keyof ScatterProps;
      for (k in oldProps) {
        if (oldProps[k] !== newProps[k] && REQUIRES_UPDATE[k]) {
          return true;
        }
      }
      return false;
    }
    • 매핑된 타입을 사용해서 관련된 값과 타입을 동기화할 수 있음
    • 인터페이스에 새로운 속성을 추가할 때, 선택을 강제하도록 매핑된 타입을 고려 해야 함


✅ 3장 타입 추론

아이템 19: 장황한 코드 방지하기

  1. 코드의 모든 변수에 타입을 선언하는 것은 비생산적

  2. 객체는 비구조화 할당문 사용 지향

    • 모든 지역 변수의 타입이 추론되도록 해야 함

      function logProduct(product: Product) {
        const { id, name, price } = product;
       
        console.log(id, name, price); // 타입 Product로 추론되어야 함
      }
  3. 타입 구문을 생략하는 경우

    • 함수 내에서 생성된 지역 변수
    • 함수 파라미터에 기본 값이 있는 경우
  4. 타입을 명시하면 좋은 경우

    • 객체 리터럴을 정의할 때, 잉여 속성 체크가 동작 함

    • 함수의 반환 타입

      • 함수의 입출력 타입에 대해 더욱 명확하게 알 수 있음

      • 명명된 타입을 사용할 수 있음

        interface Vector2D {
          x: number;
          y: number;
        }
         
        // 이 함수의 반환 타입은 Vector2D 와 호환되지 않음
        function add(a: Vector2D, b: Vector2D) {
          return { x: a.x + b.x, y: a.y + b.y };
        }
  5. cf) eslint 규칙 중 no-inferrable-types 사용 가능

    • 작성된 모든 타입 구문이 정말로 필요한지 확인

아이템 20: 다른 타입에는 다른 변수 사용하기

  1. 변수의 값은 바뀔 수 있지만, 그 타입은 바뀌지 않음

  2. 타입 확장하기 - 유니온 타입

    let id: string | number = '12-34-56';
     
    // 개선 - let 대신 const 사용
    const newId = '12-34-56';
    const serial = 123456;

아이템 21: 타입 넓히기

  1. TS가 작성된 코드를 체크하는 정적 분석 시점에, 변수는 가능한 값들의 집합인 타입을 가짐

  2. TS의 타입 넓히기

    • 지정된 단일 값을 가지고 할당 가능한 값들의 집합을 유추하는 것
      // 변수 x는 할당 시점에 넓히기가 동작해서 string으로 추론 됨
      // const 사용 지향
      let x = 'x';
  3. 넓히기를 제어하는 방법

    • const로 변수 선언

    • 객체에서 TS의 넓히기 알고리즘은 각 요소를 let으로 할당된 것처럼 다룸

      const v = { x: 1 };
       
      v.x = 3; // 정상
      v.x = '3'; // 🚨 '3' 형식은 'number' 형식에 할당할 수 없음
      v.y = 4; // 🚨 '{ x: number; }' 형식에 'y' 속성이 없음
      v.name = 'Kay'; // 🚨 '{ x: number; }' 형식에 'name' 속성이 없음
  4. TS의 기본 동작 재정의

    • 명시적 타입 구문 제공
      const v: { x: 1 | 3 | 5 } = {
        x: 1,
      }; // 타입이 { x: 1 | 3 | 5; }
  5. 타입 체커에 추가적인 문맥 제공 ex) 함수의 매개변수로 값을 전달

  6. const 단언문 사용하기 (as const)

      const v1 = {
          x: 1,
          y: 2,
      } // 타입은 { x: number, y: number; }
     
      const v2 = {
          x: 1 as const;
          y: 2,
      }; // 타입은 { x: 1, y: number; }
     
      const v3 = {
          x: 1,
          y: 2,
      } as const; // 타입은 { readonly x: 1; readonly y: 2; }

아이템 22: 타입 좁히기

  1. 분기문에서 예외를 던지거나, 함수를 반환하여 블록의 나머지 부분에서 변수의 타입 좁히기

  2. instanceof 으로 타입 좁히기

  3. 속성 체크로 타입 좁히기

    interface A {
      a: number;
    }
     
    interface B {
      b: number;
    }
     
    function pickAB(ab: A | B) {
      if ('a' in ab) {
        ab; // 타입이 A
      } else {
        ab; // 타입이 B
      }
      ab; // 타입이 a | B
    }
  4. Array.isArray 등의 내장 함수로 타입 좁히기

  5. null 체크 시 typeof null === 'object'가 됨

  6. 명시적 태그 붙이기 (tagged union)

    function handleEvent(e: AppEvent) {
      switch (e.type) {
        case 'download':
          e;
          break;
        case 'upload':
          e;
          break;
      }
    }
  7. TS를 돕기 위해 커스텀 함수 도입(사용자 정의 타입 가드)

    function isInputElement(el: HTMLElement): el is HTMLInputElement {
      return 'value' in el;
    }
  8. 배열에서 undefined 걸러내기

    function isDefined<T>(x: T | undefined): x is T {
      return x !== undefined;
    }
     
    const members = ['Janet', 'Michael']
      .map((who) => jackson5.find((n) => n === who))
      .filter(isDefined); // 타입이 string[]

아이템 23: 한꺼번에 객체 생성하기

  1. TS의 타입은 일반적으로 변경되지 않음. 따라서 객체를 생성할 때는 속성을 하나씩 추가하기보다는 여러 속성을 포함해서 한꺼번에 생성해야 타입 추론에 유리

  2. 객체를 제 각각 나눠야 한다면, 타입 단언문(as)을 사용

    interface Point {
      x: number;
      y: number;
    }
     
    const pt = {} as Point;
    pt.x = 3;
    pt.y = 4; // 정상
    • 객체 전개 연산자(...) 사용
  3. 선택적 필드 방식으로 표현

    function addOptional<T extends object, U extends object>(
      a: T,
      b: U | null
    ): T & Partial<U> {
      return { ...a, ...b };
    }
     
    const nameTitle = { name: 'Kay', title: 'Dev' };
     
    const ko = addOptional(
      nameTitle,
      hasDates ? { start: -1589, end: -1566 } : null
    );

아이템 24: 일관성 있는 별칭 사용하기

  1. 별칭을 남발하면 제어 흐름을 분석하기 어려움

  2. 객체의 속성을 별칭에 할당하면 strictNullChecks에서 걸릴 위험이 있음

    interface Polygon {
      exterior: Coordinate[];
      holes: Coordinate[][];
      box?: BoundingBox;
    }
     
    // 속성 체크는 polygon.box의 타입을 정제했지만 box는 그렇지 않음
    function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
      polygon.box; // 타입이 BoundingBox | undefined
      const box = polygon.box;
      box; // 타입이 BoundingBox | undefined
      if (polygon.box) {
        polygon.box; // 타입이 BoundingBox
        box; // 타입이 BoundingBox | undefined
      }
    }
     
    // 객체 비 구조화 할당 이용
    function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
      const { box } = polygon;
      if (box) {
        const { x, y } = box;
        // ...
      }
    }
     
    // 객체 비구조화 이용 시 주의사항
    // - 전체 box 속성이 아니라 x와 y가 선택적 속성일 경우 속성 체크가 더 필요 함
    // - box에는 선택적 속성이 적합했지만 holes에는 그렇지 않음
    // - 런타임에도 혼동을 야기할 가능성
    // - 속성보다 지역 변수 사용

아이템 25: 비동기 코드는 콜백 대신 async 함수 사용

  1. 과거 JS의 비동기 콜백 지옥 발생

    • ES2015는 Promise 개념을 도입
    • ES2017에서는 async/await 도입
    • TS 런타임에 관계없이 async/await 사용 가능
    • TS의 프로미스 반환 타입은 Promise<Response>
  2. 일반적으로 Promise보다는 async/await을 권장

    • 더 간결하고 직관적
    • async 함수는 항상 프로미스를 반환하도록 강제 됨
    // function getNumber(): Promise<number>
    async function getNumber() {
      return 42;
    }
  3. 콜백이나 프로미스를 사용하면 실수로 반(half)동기 코드를 작성할 수 있지만, async를 사용하면 항상 비동기 코드를 작성할 수 있음

    const _cache: { [url: string]: string } = {};
     
    async function fetchWithCache(url: string) {
      if (url in _cache) {
        return _cache[url];
      }
      const response = await fetch(url);
      const text = await response.text();
      _cache[url] = text;
      return text;
    }
     
    let requestStatus: 'loading' | 'success' | 'error';
     
    async function getUser(userId: string) {
      requestStatus = 'loading';
      const profile = await fetchWithCache(`/user/${userId}`);
      requestStatus = 'success';
    }
  4. async 함수에서 프로미스를 반환하면 반환 타입은 Promise<Promise<T>>가 아닌, Promise<T>가 됨

    // function getJSON(url: string): Promise<any>
    async function getJSON(url: string) {
      const response = await fetch(url);
      const jsonPromise = response.json(); // 타입이 Promise<any>
      return jsonPromise;
    }

아이템 26: 타입 추론에 문맥이 어떻게 사용되는지 이해하기

  1. 문자열 타입을 문자열 리터럴 타입의 유니온으로 사용하는 경우

    type Language = 'JavaScript' | 'TypeScript' | 'Python';
    function setLanguage(language: Language) {
      /* ... */
    }
     
    setLanguage('JavaScript'); // 정상
     
    let language = 'JavaScript';
    setLanguage(language); // 🚨 에러 language는 string type
  • 해결 방법
    • 타입 선언에서 language의 가능한 값을 제한
    let language: Language = 'JavaScript';
    setLanguage(language); // 정상
    • const를 사용하여 타입 체커에게 변경할 수 없다고 할 수 있음
  1. 튜플 사용 시 주의점

    • 위와 마찬가지로 값을 분리 당함
    function panTo(where: [number, number]) {
      /* ... */
    }
     
    panTo([10, 20]); // 정상
     
    const loc = [10, 20];
     
    // 🚨 'number[]' 형식의 인수는 '[number, number]' 형식의 매개변수에 할당될 수 없음
    panTo(loc);
    • 해결 방법

      • 타입 선언 제공

        const loc: [number, number] = [10, 20];
        panTo(loc); // 정상
      • 상수 문맥 제공

        const loc = [10, 20] as const;
         
        // 🚨 에러: 'readonly [10, 20]' 형식은 '[number, number]'에 할당할 수 없음
        panTo(loc);
      • 최선의 해결책

      function panTo(where: readonly [number, number]) {
        /* ... */
      }
       
      const loc = [10, 20] as const;
      panTo(loc); // 정상
  2. 객체 사용 시 주의점

    • 문자열 리터럴이나 튜플을 포함하는 큰 객체에서 상수를 뽑아낼 때, 프로퍼티 타입이 string으로 추론되는 경우 타입 단언이나 상수 단언을 사용할 수 있음
  3. 콜백 사용 시 주의점

    • 콜백을 다른 함수로 전달할 때, TS는 콜백의 매개변수 타입을 추론하기 위해 문맥을 사용. 이 경우 넘겨주는 함수의 매개변수에 타입 구문을 추가해서 해결할 수 있음.

아이템 27: 함수형 기법과 라이브러리로 타입 흐름 유지

  1. 함수형 프로그래밍을 지원하는 최근의 라이브러리

    • ex) map, flatMap, filter, reduce
    • 타입 정보가 그대로 유지되면서 타입 흐름(flow)이 계속 전달 됨
  2. lodash의 Dictionary 타입

    // 타입이 _.Dictionary<string>[]
    const rows = rawRows
      .slice(1)
      .map((rowStr) => _.zipObject(headers, rowStr.split(',')));
    • Dictionary<string>{[key: string]: string} 또는 Record<string, string>과 동일
  3. flat 메서드

    • T[][] => T[]
    declare const rosters: { [team: string]: BasketBallPlayer[] };
     
    // 타입이 BasketBallPlayer[]
    const allPlayers = Object.values(rosters).flat();
  4. TS의 많은 부분이 JS 라이브러리의 동작을 정확히 모델링하기 위해서 개발되었으므로, 라이브러리 사용 시 타입 정보가 잘 유지되는 점을 활용


아이템 28: 유효한 상태만 표현하는 타입을 지향하기

  1. 애플리케이션의 상태 표현하기

    interface RequestPending {
      state: 'pending';
    }
     
    interface RequestError {
      state: 'error';
      error: string;
    }
     
    interface RequestSuccess {
      state: 'ok';
      pageText: string;
    }
     
    type RequestState = RequestPending | RequestError | RequestSuccess;
     
    interface State {
      currentPage: string;
      requests: { [page: string]: RequestState };
    }
    • 모든 상황 고려하기
    • 어떤 값들을 포함하고 어떤 값들을 제외할지 신중하기 생각하기

아이템 29: 사용할 때는 너그럽게, 생성할 때는 엄격하게

  1. TCP 구현체의 견고성 원칙 또는 포스텔의 법칙(함수의 시그니처에도 적용가능)

    • 함수의 매개변수는 타입의 범위가 넓어도 되지만, 결과를 반환할 때는 일반적으로 타입의 범위가 더 구체적이어야 함
  2. 예시

    • 👎 Bad Case
    declare function setCamera(camera: CameraOptions): void;
    declare function viewportForBounds(bounds: LngLatBounds): CameraOptions;
     
    interface CameraOptions {
      center?: LngLat;
      zoom?: number;
      bearing?: number;
      pitch?: number;
    }
     
    type LngLat =
      | { lng: number; lat: number }
      | { lon: number; lat: number }
      | [number, number];
    • 👍 Good Case
    interface LngLat {
      lng: number;
      lat: number;
    }
     
    type LngLatLike = LngLat | { lon: number; lat: number } | [number, number];
     
    interface Camera {
      center: LngLat;
      zoom: number;
      bearing: number;
      pitch: number;
    }
     
    interface CameraOptions extends Omit<Partial<Camera>, 'center'> {
      center?: LngLatLike;
    }
     
    type LngLatBounds =
      | { northeast: LngLatLike; southwest: LngLatLike }
      | [LngLatLike, LngLatLike]
      | [number, number, number, number];
     
    declare function setCamera(camera: CameraOptions): void;
    declare function viewportForBounds(bounds: LngLatBounds): Camera;

    → 매개변수와 반환 타입의 재사용을 위해서 기본 형태(반환 타입)와 느슨한 형태(매개변수 타입)를 지향


아이템 30: 문서에 타입 정보를 쓰지 않기

  1. 타입 구문은 TS 타입 체커가 타입 정보를 동기화하도록 강제
  2. 함수의 입력과 출력의 타입을 코드로 표현하는 것이 주석보다 더 나음
  3. 값을 변경하지 않는다고 설명하는 주석 대신, readonly 사용
  4. 변수명에 타입 정보 넣지 않기 (단위가 있는 숫자들은 제외)

아이템 31: 타입 주변에 null 값 배치하기

  1. 문제가 있는 예제
// 최솟값이나 최댓값이 0인 경우
// numbs 배열이 비어있는 경우
function extent(numbs: number[]) {
  let min, max;
 
  for (const num of numbs) {
    if (!min) {
      min = num;
      max = num;
    } else {
      min = Math.min(min, num);
      max = Math.max(max, num);
    }
  }
 
  return [min, max];
}
  • min과 max를 한 객체 안에 넣고 null이거나 null이 아니게 하기
function extent(numbs: number[]) {
  let result: [number, number] | null = null;
 
  for (const num of numbs) {
    if (!result) {
      result = [num, num];
    } else {
      result = [Math.min(num, result[0]), Math.max(num, result[1])];
    }
  }
 
  return [min, max];
}
  • nullnull이 아닌 값을 섞어서 클래스 만들기
class userPosts {
  user: UserInfo;
  posts: Post[];
 
  constructor(user: UserInfo, posts: Post[]) {
    this.user = user;
    this.posts = posts;
  }
 
  static async init(userId: string): Promise<UserPosts> {
    const [user, posts] = await Promise.all([
      fetchUser(userId),
      fetchPostsForUser(userId),
    ]);
 
    return new UserPosts(user, posts);
  }
 
  getUserName() {
    return this.user.name;
  }
}
  1. 정리
    • 값들 중 null 여부에 따라, 다른 값이 암시적으로 null이 될 수있는 가능성을 두고 설계하면 안 됨
    • API 작성 시에는 반환 타입을 큰 객체로 만들고, 반환 타입 전체가 null 이거나 null이 아니게 만들어야 함
    • 클래스를 만들 때는 필요한 모든 값이 준비되었을 때, 생성하여 null이 존재하지 않도록 하는 것이 좋음

아이템 32: 인터페이스의 유니온을 사용하기

유니온의 인터페이스보다는 인터페이스의 유니온을 사용하기

  1. 문제가 있는 예제

    interface Layer {
      layout: FillLayout | LineLayout | PointLayout;
      paint: FillPaint | LinePaint | PointPaint;
    }

    → 각각 타입의 계층을 분리된 인터페이스로 나누기

    interface FillLayer {
      type: 'fill';
      layout: FillLayout;
      paint: FillPaint;
    }
     
    interface LineLayer {
      type: 'line';
      layout: LineLayout;
      paint: LinePaint;
    }
     
    interface PointLayer {
      type: 'point';
      layout: PointLayout;
      paint: PointPaint;
    }
     
    type Layer = FillLayer | LineLayer | PointLayer;
  2. 태그드 유니온 사용(TS는 태그를 참고하여 범위를 좁힐 수 있음)

      function drawLayer(layer: Layer) {
        if (layer.type === 'fill') {
          const { paint } = layer; // 타입이 FillPaint
          const { layout } = layer; // 타입이 FillLayout
        } else // ...
  3. 여러 개의 선택적 필드가 동시에 값이 있거나 동시에 undefined인 경우, 두 개의 속성을 하나의 객체로 모음

    interface Person {
      name: string;
     
      // birthPlace와 birthDate를 하나로 모음
      birth?: {
        place: string;
        date: Date;
      };
    }

아이템 33: string 타입보다 더 구체적인 타입 사용하기

  1. 좋지 못한 예시

    interface Album {
      artist: string;
      title: string;
      releaseDate: string;
      recordingType: string;
    }
  2. 타입을 제한하거나, 유니온 타입을 사용하

    type RecordingType = 'studio' | 'live';
     
    interface Album {
      artist: string;
      title: string;
      releaseDate: Date;
      recordingType: RecordingType;
    }
  3. 함수의 매개변수에 string을 잘못 사용하지 않도록 주의

    // 🚨 '{}' 형식에 인덱스 시그니처가 없으므로 요소에 암시적으로 'any' 형식이 있음
    function pluck(records: any[], key: string): any[] {
      return records.map((r) => r[key]);
    }
  • 제네릭과 keyof을 사용

    type K = keyof Album;
     
    // 이때 TS는 반환 타입을 추론함
    function pluck<T>(records: T[], key: keyof T) {
      return records.map((r) => r[key]);
    }
  • keyof T로 범위 더 좁힐 수 있음

    function pluck<T, K extends keyof T>(records: T[], key: K): T[K][] {
      return records.map((r) => r[key]);
    }
  • 결과

    pluck(albums, 'releaseDate'); // 타입이 Date[]
    pluck(albums, 'artist'); // 타입이 string[]
    pluck(albums, 'recordingType'); // 타입이 RecordingType[]

아이템 34: 부정확한 타입보다는 미완성 타입 사용하기

  1. 코드를 더 정밀하게 만들어서, 코드가 오히려 더 부정확해지는 문제

    interface Point {
      type: 'Point';
      coordinates: number[];
    }
     
    interface LineString {
      type: 'LineString';
      coordinates: number[][];
    }
     
    interface Polygon {
      type: 'Polygon';
      coordinates: number[][];
    }
     
    type Geometry = Point | LineString | Polygon; // 다른 것들도 추가될 수 있다
  2. 아래와 같이 구체화하는 경우 GeoPosition 위치정보에는 추가 정보가 들어갈 수 없게 됨

    type GeoPosition = [number, number];
     
    interface Point {
      type: 'Point';
      coordinates: GeoPosition;
    }
  3. 부정확함을 바로잡는 방법을 쓰는 대신, 테스트 세트를 추가하여 놓친 부분이 없는지 확인

    type CallExpression = MathCall | CaseCall | RGBCall;
    type Expression = number | string | CallExpression;
     
    interface MathCall {
      0: '+' | '-' | '/' | '*' | '>' | '<';
      1: Expression;
      2: Expression;
      length: 3;
    }
     
    interface CaseCall {
      0: 'case';
      1: Expression;
      2: Expression;
      3: Expression;
      length: 4 | 6 | 8 | 10 | 12 | 14 | 16; // 등등
    }
     
    interface RGBCall {
      0: 'rgb';
      1: Expression;
      2: Expression;
      3: Expression;
      length: 4;
    }

    → 잘못 사용된 코드에서 오류가 발생하기는 하지만, 오류 메시지가 더 난해 해짐

  4. 타입이 구체적으로 정제된다고 해서 정확도가 무조건 올라가지는 않음

  5. 현 상황을 고려하면서 타입 설계


아이템 35: API와 명세를 보고 타입 만들기

  • 명세를 기반으로 타입을 작성한다면, 사용 가능한 모든 값에 대해서 코드가 작동한다는 확신을 가질 수 있음

아이템 36: 해당 분야의 용어로 타입 이름 짓기

  • 동일한 의미를 나타낼 때는 같은 용어를 사용
  • data, info, thing, item, object, entity 같은 모호하고 의미없는 이름 지양
  • 네이밍 할 때, 포함된 내용이나 계산 방식이 아니라, 데이터 자체가 무엇인지를 고려

아이템 37: 공식 명칭에는 상표를 붙이기

  1. 공식 명칭 (nominal typing)

    • 타입이 아니라, 값의 관점
    interface Vector2D {
      x: number;
      y: number;
      _brand: '2d';
    }
     
    function vec2D(x: number, y: number): Vector2D {
      return { x, y, _brand: '2d' };
    }
     
    function calculateNorm(p: Vector2D) {
      return Math.sqrt(p.x * p.x + p.y * p.y);
    }
     
    calculateNorm(vec2D(3, 4)); // 정상
     
    const vec3D = { x: 3, y: 4, z: 1 };
     
    calculateNorm(vec3D); // 🚨 '_brand' 속성이 ... 형식에 없습니다
  2. 상표 시스템은 타입 시스템에서 동작하지만, 런타임에 상표를 검사하는 것과 동일한 효과를 얻을 수 있음

  3. TS는 구조적 타이핑(덕 타이핑)을 사용하기 때문에, 값을 구분하기 위해 공식 명칭이 필요할 경우 상표를 붙일 수 있음


아이템 38: any 타입은 한 좁은 범위에서만 사용하기

  1. any 작성 방식

    function f1() {
      const x: any = expressionReturningFoo(); // X
      processBar(x);
    }
     
    function f2() {
      const x = expressionReturningFoo(); // O
      processBar(x as any);
    }
    • any 타입이 processBar 함수의 매개변수에만 사용된 표현식이므로 다른 코드에는 영향을 미치지 않기 때문
  2. TS가 함수의 반환 타입을 추론할 수 있는 경우에도 함수의 반환 타입을 명시하는 것이 좋음

  3. 강제로 타입 오류 제거 시 any 대신 @ts-ignore 사용

    // 근본적인 문제 해결은 아님
     
    function f1() {
      const x = expressionReturningFoo();
      // @ts-ignore
      processBar(x);
      return x;
    }
  4. 객체와 관련한 any의 사용법

    // 모든 속성이 타입 체크가 되지 않는 부작용 발생
    const config: Config = {
      a: 1,
      b: 2,
      c: {
        key: value,
      },
    } as any; // X
     
    const config: Config = {
      a: 1,
      b: 2, // 이 속성은 여전히 체크됨
      c: {
        key: value as any,
      },
    };

아이템 39: any를 구체적으로 변형해서 사용하기

  1. 일반적인 상황에서는 any보다 더 구체적으로 표현할 수 있는 타입이 존재할 가능성이 높음

    function getLengthBad(array: any) {
      // X
      return array.length;
    }
     
    function getLength(array: any[]) {
      return array.length;
    }
  2. 함수 매개변수로 객체 사용 시 타입 구체화

    function hasTwelveLetterKey(o: { [key: string]: any }) {
      for (const key in o) {
        if (key.length === 12) {
          return true;
        }
      }
      return false;
    }
  3. 함수 타입 구체화

    type Fn0 = () => string; // 매개변수 없이 호출 가능한 모든 함수
    type Fn1 = (arg: string[]) => string[]; // 매개변수 1개
    type FnN = (...args: string[]) => string[]; // 모든 개수의 매개변수 ("Function" 타입과 동일)

아이템 40: 함수 안으로 타입 단언문 감추기

  1. 함수 내부에는 타입 단언 사용하고, 함수 외부로 드러나는 타입은 정의를 정확히 명시하는 것이 좋음
  • 어떤 함수든 캐싱할 수 있는 래퍼 함수 cacheWrapper

    declare function cacheWrapper<T extends Function>(fn: T): T;
    declare function shallowEqual(a: any, b: any): boolean;
     
    // TS는 반환문에 있는 함수와 원본 함수 T 타입이 어떤 관련이 있는지 알지 못하기 때문에 오류 발생
    function cacheWrapper<T extends Function>(fn: T): T {
      let lastArgs: any[] | null = null;
      let lastResult: any;
     
      return function (...args: any[]) {
        // 🚨 '(...args: any[]) => any' 형식은 'T' 형식에 할당할 수 없습니다.
        if (!lastArgs || !shallowEqual(lastArgs, args)) {
          lastResult = fn(...args);
          lastArgs = args;
        }
        return lastResult;
      };
    }
  • 단언문을 추가해서 오류를 제거

    function cacheWrapper<T extends Function>(fn: T): T {
      let lastArgs: any[] | null = null;
      let lastResult: any;
     
      return function (...args: any[]) {
        if (!lastArgs || !shallowEqual(lastArgs, args)) {
          lastResult = fn(...args);
          lastArgs = args;
        }
        return lastResult;
      } as unknown as T;
    }
  • 객체를 매개변수로 하는 shallowObjectEqual

    declare function shallowObjectEqual<T extends object>(a: T, b: T): boolean;
     
    function shallowObjectEqual<T extends object>(a: T, b: T): boolean {
      for (const [(k, value)] of Object.entries(a)) {
        if (!(k in b) || value !== (b as any)[k]) {
          // b[k] 구문에 타입 단언 필요
          return false;
        }
      }
      return Object.keys(a).length === Object.keys(b).length;
    }

아이템 41: Any 타입의 변환

  1. 예제 코드

    // out의 타입은 any[]로 선언되었지만,
    // number 타입의 값을 넣는 순간부터 타입은 number[]로 변환
    function range(start: number, limit: number) {
      const out = []; // 타입이 any[]
     
      for (let i = start; i < limit; i++) {
        out.push(i); // out의 타입이 any[]
      }
     
      return out; // 타입이 number[]
    }
  2. 타입의 전환

  • 배열에 다양한 타입의 요소를 넣으면 배열의 타입이 변환됨

    const result = []; // 타입 any[]
    result.push('a'); // 타입 string[]
     
    result.push(1);
    result; // 타입 (string | number)[]
  1. 기타

    • 조건문에서는 분기에 따라 타입이 변환
    • 변수의 초깃값이 null인 경우도 any의 변환 발생
  2. any 타입의 변환은 noImplicitAny가 설정된 상태에서 변수의 타입이 암시적 any인 경우에만 발생한며, 명시적 any 선언 시 타입이 그대로 유지됨

  3. any 타입의 변환은 암시적 any 타입에 어떤 값을 할당할 때만 발생하며, 암시적 any 타입은 함수 호출을 거쳐도 변환되지 않음

  4. 타입을 안전하게 지키기 위해서는 암시적 any를 진화시키는 방식보다, 명시적 타입 구문을 사용하는 것이 좋음


아이템 42: any 대신 unknown 사용하기

  1. 함수의 반환값에 unknown 사용

    function parseYAML(yaml: string): any {
      // ...
    }
     
    function safeParseYAML(yaml: string): unknown {
      return parseYAML(yaml);
    }
     
    const book = safeParseYAML(`
      name: Kay
      author: Charlotte Bronte
    `) as Book;
     
    alert(book.title); // 🚨 'Book' 형식에 'title' 속성이 없습니다.
    book('read'); // 🚨 이 식은 호출할 수 없습니다.
  2. any가 강력하면서도 위험한 이유

    • 어떠한 타입이든 any 타입에 할당 가능
    • 어떠한 타입이든 unknown 타입에 할당 가능
    • 어떠한 타입도 never에 할당할 수 없음
    • any 타입은 어떠한 타입으로도 할당 가능
    • unknown은 오직 unknownany에만 할당 가능
    • never 타입은 어떠한 타입으로도 할당 가능 → 타입 시스템과 상충됨
  3. instanceof 체크 후 unknown에서 원하는 타입으로 변환

    function processValue(val: unknown) {
      if (val instanceof Date) {
        val; // 타입이 Date
      }
    }
  4. 사용자 정의 타입 가드로 unknown에서 원하는 타입으로 변환

    function isBook(val: unknown): val is Book {
      return (
        typeof val === 'object' &&
        val !== null &&
        'name' in val &&
        'author' in val
      );
    }
     
    function processValue(val: unknown) {
      if (isBook(val)) {
        val; // 타입이 Book
      }
    }
  5. unknown 대신 제네릭 매개변수 사용

    //  타입 단언문과 똑같음
    // 제네릭보다는 unknown을 반환하고, 사용자가 직접 단언문을 사용하거나 원하는 대로 타입을 좁히도록 강제하는 것이 좋음
    function safeParseYAML<T>(yaml: string): T {
      return parseYAML(yaml);
    }
  6. 단언문

    declare const foo: Foo;
     
    let barAny = foo as any as Bar;
    let barUnk = foo as unknown as Bar;
    • unknown의 경우 분리되는 즉시 오류를 발생하므로 any보다 안전(에러가 전파되지 않음)
  7. 정말 nullundefined가 불가능하다면, unknown 대신 {} 사용


아이템 43: 몽키 패치보다는 안전한 타입을 사용하기

  1. JS는 객체나 클래스에 임의의 속성을 추가할 수 있음

    window.monkey = 'Tamarin';
    document.monkey = 'Howler';
     
    // 'Document' 유형에 'monkey' 속성이 없습니다
    document.monkey = 'Tamarin';
     
    // 해결
    // 단 타입 안정성을 해치는 안 좋은 코드
    (document as any).monkey = 'Tamarin'; // 정상
    • 일반적으로 좋은 설계는 아님(전역 변수 사이드 이펙트의 문제)
  2. interface 의 보강(augmentation)

    • 보강은 전역적으로 적용되기 때문에, 코드의 다른 부분이나 라이브러리로부터 분리할 수 없음

      interface Document {
        monkey: string;
      }
       
      document.monkey = 'Tamarin'; // 정상
       
      // 모듈 관점에서라면 global 선언 추가
      export {};
       
      declare global {
        interface Document {
          monkey: string;
        }
      }
       
      document.monkey = 'Tamarin'; // 정상
  3. 더 구체적인 타입 단언문 사용

    interface MonkeyDocument extends Document {
      monkey: string;
    }
     
    (document as MonkeyDocument).monkey = 'Macaque'; // 정상

아이템 44: 타입 커버리지 추적해 타입 안전성 유지

  1. any 타입이 여전히 프로그램 내에 존재할 수 있는 2가지 경우
  • 명시적 any 타입 ex) any[], {[key: string]: any}

  • 서드파티 타입 선언

    • @types 선언 파일로부터 any 타입이 전파되는 경우

    • 가장 극단적인 예시는 전체 모듈에 any 타입을 부여하는 경우

      // my-module 에서 어떤 것이든 오류 없이 임포트할 수 있음
      declare module 'my-module';
    • 타입에 버그가 있는 경우 : 선언된 타입과 실제 반환된 타입이 맞지 않는 경우

  1. npm의 type-coverage 패키지 활용하여 any 추적하기

아이템 45: devDependencies에 TS,@types 추가

  1. npm의 의존성 구분

    • dependencies: 현재 프로젝트 실행 시 필수적인 라이브러리
    • devDependencies: 런타임에는 필요없는 라이브러리
    • peerDependencies: 런타임에 필요하긴 하지만, 의존성을 직접 관리하지 않는 라이브러리
  2. TS는 개발 도구일 뿐이고 타입 정보는 런타임에 존재하지 않기 때문에, TS와 관련된 라이브러리는 일반적으로 devDependencies에 속함

  3. TS 프로젝트에서 고려해야 할 의존성

    • TS 시스템 레벨로 설치하기보다는 devDependencies에 넣는 것을 권장
      • npm install 시 팀원들 모두 항상 정확한 버전의 TS 설치 가능
    • 대부분의 TS IDE와 빌드 도구는 devDependencies를 통해 설치된 타입스크립트의 버전을 인식할 수 있음
    • DefinitelyTyped에서 라이브러리에 대한 타입 정보를 얻을 수 있음
    • @types 라이브러리는 타입 정보만 포함하고 있으며 구현체는 포함하지 않음
    • 원본 라이브러리 자체가 dependencies에 있더라도 @types 의존성은 devDependencies에 있어야 함

아이템 46: 타입 선언과 관련된 3가지 버전 이해하기

  1. TS 사용 시 고려해야 할 사항

    • 라이브러리의 버전
    • 타입 선언(@types)의 버전
    • TS의 버전
  2. TS에서 의존성을 사용하는 방식

    • 특정 라이브러리는 dependencies로, 타입 정보는 devDependencies로 설치
  3. 실제 라이브러리와 타입 정보의 버전이 별도로 관리되는 방식의 문제점

    1. 라이브러리를 업데이트했지만 실수로 타입 선언은 업데이트하지 않은 경우

      • 타입 선언도 업데이트하여 라이브러리와 버전을 맞춤
      • 보강 기법 또는 타입 선언의 업데이트를 직접 작성
    2. 라이브러리보다 타입 선언의 버전이 최신인 경우

      • 라이브러리 버전을 올리거나 타입 선언의 버전을 내리기
    3. 프로젝트에서 사용하는 TS 버전보다 라이브러리에서 필요로 하는 타입스크립트 버전이 최신인 경우

      • TS의 최신 버전을 사용
      • 라이브러리 타입 선언의 버전을 내리거나, declare module 선언으로 라이브러리의 타입 정보를 없애 버림
    4. @types 의존성이 중복되는 경우

      • ex) @types/bar가 현재 호환되지 않는 버전의 @types/foo에 의존하는 경우

      • 전역 네임스페이스에 있는 타입 선언 모듈인 경우 중복 문제가 발생 → 서로 버전이 호환되도록 업데이트

      • 일부 라이브러리는 자체적으로 타입 선언을 포함(번들링)

      • package.jsontypes 필드가 .d.ts 파일을 가리키도록 되어 있음

      • 버전 불일치 문제를 해결할 수 있지만, 네 가지 부수적인 문제점이 있음

        • 번들된 타입 선언에 보강 기법으로 해결할 수 없는 오류가 있는 경우, 또는 공개 시점에는 잘 동작했지만 TS 버전이 올라가면서 오류가 발생하는 경우(번들된 타입에서는 @types의 버전 선택 불가능)
        • 프로젝트 내의 타입 선언이 다른 라이브러리의 타입 선언에 의존하는 경우(devDependencies에 들어간 의존성을 다른 사용자는 설치할 수 없기 때문) → DefinitelyTyped에 타입 선언을 공개하여 타입 선언을 @types로 분리
        • 프로젝트의 과거 버전에 있는 타입 선언에 문제가 있는 경우 → 과거 버전으로 돌아가서 패치 업데이트를 함
        • 타입 선언의 패치 업데이트를 자주 하기 어렵다는 문제
  4. 잘 작성된 타입 선언은 라이브러리를 올바르게 사용하는 방법에 도움이 되며 생산성을 크게 향상시킴

  5. 라이브러리 공개 시, 타입 선언을 자체적으로 포함하는 것과 타입 정보만 분리하여 DefinitelyTyped에 공개하는 것의 장단점을 비교 해야 함

  6. 라이브러리가 타입스크립트로 작성된 경우만 타입 선언을 라이브러리에 포함하는 것을 권장


아이템 47: Public API 모든 타입 Export

  1. 라이브러리 제작자는 프로젝트 초기에 타입 Export 부터 작성해야 함

    • 타입을 Export 하지 않았을 경우

      // 해당 라이브러리 사용자는 SecretName 또는 SecretSanta 를 직접 import 할 수 없고, getGift만 import 할 수 있음
       
      interface SecretName {
        first: string;
        last: string;
      }
       
      interface SecretSanta {
        name: SecretName;
        gift: string;
      }
       
      export function getGift(name: SecretName, gift: string): SecretSanta {
        // ...
      }
    • ParametersReturnType을 이용해 추출

      type MySanta = ReturnType<typeof getGift>; // SecretSanta
      type MyName = Parameters<typeof getGift>[0]; // SecretName

      → 사용자가 추출하기 전에 공개 메서드에 사용된 타입은 Export 지향


아이템 48: API 주석에 TSDoc 사용하기

  1. 함수 주석에 // ... 대신 JSDoc 스타일의 /** ... **/ 을 사용하면 대부분의 편집기는 함수 사용부에서 주석을 툴팁으로 표시해 줌

  2. 타입스크립트 관점의 TSDoc

    /**
     * Generate a greeting
     * @param name Name of the person to greet
     * @param title ...
     * returns ...
     */
    function greetFullTSDoc(name: string, title: string) {
      return `Hello ${title} ${name}`;
    }
  3. 타입 정의에 TSDoc 사용하기

    /** 특정 시간과 장소에서 수행된 측정 */
    interface Measurement {
      /** 어디에서 측정되었나? */
      position: Vector3D;
     
      /** 언제 측정되었나? */
      time: number;
     
      /** 측정된 운동량 */
      momentum: Vector3D;
    }
     
    // Measurement 객체의 각 필드에 마우스를 올려 보면 필드별로 설명을 볼 수 있음
  4. TS에서는 타입 정보가 코드에 있기 때문에 TSDoc에서는 타입 정보를 명시하면 안 됨(주의)


아이템 49: 콜백에서 this에 대한 타입 제공하기

  1. JS에서 this는 다이나믹 스코프

    • 정의된 방식이 아니라 호출된 방식에 따라 달라짐
  2. TS는 JS의 this 바인딩을 그대로 모델링 함

  3. this를 사용하는 콜백 함수에서 this 바인딩 문제 해결

    • 콜백 함수의 매개변수에 this를 추가하고, 콜백 함수를 call로 호출하는 방법

      // 이때 반드시 call 을 사용해야 함
      function addKeyListener(
        el: HTMLElement,
        fn: (this: HTMLElement, e: KeyboardEvent) => void
      ) {
        el.addEventListener('keydown', (e) => {
          fn.call(el, e);
        });
      }
    • 만약 라이브러리 사용자가 콜백을 화살표 함수로 작성하고 this를 참조하려고 하면 TS가 문제를 잡아 냄

      class Foo {
        registerHandler(el: HTMLElement) {
          addKeyListener(el, (e) => {
            this.innerHTML; // 'Foo' 유형에 'innerHTML' 속성이 없음
          });
        }
      }
  4. 콜백 함수에서 this 값을 사용해야 한다면, this는 API의 일부가 되는 것이기 때문에 반드시 타입 선언에 포함해야 함


아이템 50: 오버로딩 타입보다는 조건부 타입 사용

  1. 두 가지 타입의 매개변수를 받는 함수

    // 선언문에는 number 타입을 매개변수로 넣고 string 타입을 반환하는 경우도 포함되어 있음
    function double(x: number | string): number | string;
    function double(x: any) {
      return x + x;
    }
     
    const num = double(12); // string | number
    const str = double('x'); // string | number

    → 제네릭을 사용하여 동작을 모델링할 수 있음

    // 타입이 너무 과하게 구체적인 문제
    function double<T extends number | string>(x: T): T;
    function double(x: any) {
      return x + x;
    }
     
    const num = double(12); // 타입이 12
    const str = double('x'); // 타입이 'x' (😮 string을 원하고 있다.)
  2. 조건부 타입

  • 타입 공간의 if 구문

    function double<T extends number | string>(
      x: T
    ): T extends string ? string : number;
     
    function double(x: any) {
      return x + x;
    }
  • 개별 타입의 유니온으로 일반화하기 때문에 타입이 더 정확해짐

  • 각각이 독립적으로 처리되는 타입 오버로딩과 달리, 조건부 타입은 타입 체커가 단일 표현식으로 받아들이기 때문에 유니온 문제를 해결할 수 있음


아이템 51: 의존성 분리를 위해 미러 타입 사용

  1. CSV 파일을 파싱하는 라이브러리 작성 시, NodeJS 사용자를 위해 매개변수에 Buffer 타입을 허용하는 경우

    • Buffer 타입 정의를 위해 @types/node 패키지 필요
    • 그러나 다른 라이브러리 사용자들은 해당 패키지가 불필요
  2. 각자가 필요한 모듈만 사용할 수 있도록 구조적 타이핑 적용

    // CsvBuffer가 Buffer 타입과 호환되기 때문에 NodeJS 프로젝트에서도 사용 가능
    interface CsvBuffer {
      toString(encoding: string): string;
    }
    function parseCSV(
      contents: string | CsvBuffer
    ): { [column: string]: string }[] {
      // ...
    }
     
    parseCSV(new Buffer('column1, column2\nval2,val2', 'utf-8'));
  3. 미러링

    • 작성 중인 라이브러리가 의존하는 라이브러리의 구현과 무관하게 타입에만 의존한다면, 필요한 선언부만 추출하여 작성 중인 라이브러리에 넣는 것
  4. 다른 라이브러리의 타입이 아닌 구현에 의존하는 경우에도 동일한 기법을 적용할 수 있고 타입 의존성을 피할 수 있음

→ 유닛 테스트와 상용 시스템 간의 의존성을 분리하는 데도 유용


아이템 52: 테스팅 타입의 함정에 주의하기

  1. 타입 선언 테스트

    • 유틸리티 라이브러리에서 제공하는 map 함수의 타입 작성
    // 단순히 함수를 호출하는 테스트만으로는 반환값에 대한 체크가 누락될 수 있음 (’실행’에서의 오류만 검사함)
    declare function map<U, V>(array: U[], fn: (u: U) => V): V[];
  2. 반환값을 특정 타입의 변수에 할당하여 간단히 반환 타입을 체크할 수 있는 방법

    // number[] 타입 선언은 map 함수의 반환 타입이 number[] 임을 보장
     
    const lengths: number[] = map(['john', 'paul'], (name) => name.length);
  3. 그러나 테스팅을 위해 할당을 사용하는 방법에는 두 가지 문제가 있음

    • 불필요한 변수를 만들어야 함 그래서 일반적인 해결책은 변수 도입 대신 헬퍼 함수를 정의하는 것

      function assertType<T>(x: T) {}
      assertType<number[]>(map(['john', 'paul'], (name) => name.length));
    • 두 타입이 동일한지 체크하는 것이 아니라 할당 가능성을 체크

      • 객체의 타입을 체크하는 경우

        const beatles = ['john', 'paul', 'george', 'ringo'];
         
        // 반환된 배열은 {name: string}[] 에 할당 가능하지만, inYellowSubmarine 속성에 대한 부분이 체크되지 않음
        assertType<{ name: string }[]>(
          map(beatles, (name) => ({
            name,
            inYellowSubmarine: name === 'ringo',
          }))
        ); // 정상
      • TS의 함수는 매개변수가 더 적은 함수 타입에 할당 가능하다는 문제

        const double = (x: number) => 2 * x;
        assertType<(a: number, b: number) => number>(double); //  정상?!
      • ParametersReturnType 제네릭 타입을 이용해, 함수의 매개변수 타입과 반환 타입만 분리하여 테스트할 수 있음

      const double = (x: number) => 2 * x;
      let p: Parameters<typeof double> = null;
       
      assertType<[number, number]>(p);
       
      // 🚨 '[number]' 형식의 인수는 '[number, number]' 형식의 매개변수에 할당될 수 없습니다
      let r: ReturnType<typeof double> = null;
      assertType<number>(r); // 정상
      • map의 콜백 함수에서 사용하게 되는 this 값에 대한 타입 선언 테스트
      declare function map<U, V>(
        array: U[],
        fn: (this: U[], u: U, i: number, array: U[]) => V
      ): V[];
  4. 타입 시스템 내에서 암시적 any 타입을 발견하기 위해 DefinitelyTyped의 타입 선언을 위한 도구 tslint 사용함

    // tslint는 할당 가능성을 체크하는 대신 각 심벌의 타입을 추출하여 글자 자체가 같은지 비교한다
    const beatles = ['john', 'paul', 'george', 'ringo'];
     
    map(
      beatles,
      function (
        name, // $ExpectType string
        i, // $ExpectType number
        array // $ExpectType string[]
      ) {
        this; // $ExpectType string[]
        return name.length; // $ExpectType number[]
      }
    );

아이템 53: TS 기능보다 ECMAScript 기능 사용하기

  1. JS에 새로 추가된 기능은 TS의 초기 기능과 호환성 문제를 발생

    • JS의 신규 기능을 그대로 채택하고 TS 초기 버전과 호환성을 포기. 그러나 이미 사용되고 있던 몇 가지 기능(호환성 문제로 지양하는 방식) 있음
  2. 열거형(enum)

    • 몇몇 값의 모음을 나타내는 방식

    • 문제점

      • 숫자 열거형에 0, 1, 2 외의 다른 숫자가 할당되면 매우 위험

      • 상수 열거형(const enum)은 런타임에 완전히 제거되어, 문자열 열거형에서 문제를 일으킴

      • preserveConstEnums 플래그를 설정한 상수 열거형은 런타임 코드에 정보를 유지함

      • 문자열 열거형은 구조적 타이핑이 아닌 명목적 타이핑을 사용함

      • 문자열 열거형의 명목적 타이핑은 JS와 동작이 다르다는 문제가 있음

        enum Flavor {
          VANILLA = 'vanilla',
          CHOCOLATE = 'chocolate',
          STRAWBERRY = 'strawberry',
        }
         
        let flavor = Flavor.CHOCOLATE; // 타입이 Flavor
        flavor = 'strawberry'; // 🚨 'strawberry' 형식은 'Flavor' 형식에 할당할 수 없습니다
         
        // 열거형 대신 리터럴 타입의 유니온 사용을 권장
        type Flavor = 'vanilla' | 'chocolate' | 'strawberry';
  3. 매개변수 속성

    • 생성자의 매개변수를 사용하여 클래스 초기화 시 TS는 간결한 문법을 제공

      class Person {
        constructor(public name: string) {}
      }
      • 문제점
        • 실제로는 코드가 늘어남
        • 매개변수 속성은 런타임에는 실제로 사용되지만, TS에서는 사용되지 않는 것처럼 보임
        • 매개변수 속성과 일반 속성을 섞어서 사용하면 클래스의 설계가 혼란스러워 짐
  4. 네임스페이스와 트리플 슬래시 임포트

    // ES2015 스타일의 모듈(import와 export) 사용을 권장
     
    namespace foo {
      function bar() {}
    }
     
    /// <reference path="other.ts" />
    foo.bar();
  5. 데코레이터

    • 클래스, 메서드, 속성에 annotation을 붙이거나 기능을 추가하는 것
    • 문제점
      • 표준화가 완료되지 않았기 때문에 비표준으로 바뀌거나 호환성이 깨질 가능성이 있음

아이템 54: 객체를 순회하는 노하우

  1. 편집기에서 오류가 발생하는 경우

    const obj = {
      one: 'uno',
      two: 'dos',
      three: 'tres',
    };
     
    for (const k in obj) {
      const v = obj[k];
      // 🚨 obj에 인덱스 시그니처가 없기 때문에 엘리먼트는 암시적으로 'any' 타입
    }
     
    // k가 string 으로 인식되기 때문
    // k의 타입을 더욱 구체적으로 명시해서 해결가능
    let k: keyof typeof obj;
    for (k in obj) {
      const v = obj[k]; // 정상
    }
    • kstring으로 추론된 이유

      // a, b, c 외에 다른 속성이 존재하는 객체도 foo 함수의 매개변수 abc에 할당 가능하기 때문
      interface ABC {
        a: string;
        b: string;
        c: number;
      }
       
      function foo(abc: ABC) {
        for (const k in abc) {
          const v = abc[k]; // 🚨
        }
      }
  2. keyof사용시 문제점

    • vstring | number로 한정되어 범위가 너무 좁아짐
  3. 단지 객체의 키와 값을 순회하고 싶다면 Object.entries를 사용

    function foo(abc: ABC) {
      for (const [k, v] of Object.entries(abc)) {
        k; // string 타입
        v; // any 타입
      }
    }

아이템 55: DOM 계층 구조 이해하기

  1. DOM 엘리먼트를 사용할 때 TS 에러

    • EventTarget : DOM 타입 중 가장 추상화된 타입으로, 이벤트리스너의 추가/제거, 이벤트 보내기만 가능

      // 'EventTarget' 형식에 'classList' 속성이 없음
      // Event의 currentTarget 속성의 타입은 EventTarget | null
      function handleDrag(eDown: Event) {
        const targetEl = eDown.currentTarget;
        targetEl.classList.add('dragging');
      }
    • Node : Element가 아닌 Node, 텍스트 조각과 주석

    • ElementHTMLElement : HTML이 아닌 엘리먼트, SVGSvgElement

    • HTMLxxxElement

      • HTMLxxxElement 형태의 특정 엘리먼트들은 자신만의 고유한 속성을 가지고 있음 ex) HTMLImageElement(src), HTMLInputElement(value)

      • 항상 정확한 타입을 얻을 수 있는 것은 아님

        // 정확한 타입
        document.createElement('button'); // HTMLButtonElement
         
        // 정확한 타입이 아닌 경우
        document.getElementById('my-div'); // HTMLElement
      • 타입 단언문 사용

        document.getElementById('my-div') as HTMLDivElement;
    • strictNullChecks 설정 시, 엘리먼트가 null인 경우를 체크함

    • Event 는 가장 추상화된 이벤트로, 별도의 계층구조를 가짐

      • ex) UIEvent, MouseEvent, TouchEvent, WheelEvent, KeyboardEvent
      • 더 많은 문맥 정보를 제공하여 DOM에 대한 타입 추론을 가능하게 해야 함

아이템 56: 정보를 감추는 목적으로 private 사용 X

  1. public, protected, private 같은 접근 제어자

    • TS 키워드기 때문에 컴파일 후에 제거 됨
  2. 심지어 단언문을 사용하면 TS 상태에서도 private 속성에 접근 가능

    // 정보를 감추기 위해 `private` 을 사용하면 안 됨
    class Diary {
      private secret = 'test';
    }
     
    const diary = new Diary();
    (diary as any).secret; // 정상
  3. 정보를 감추기 위해 클로저 사용

    // PasswordChecker 의 생성자 외부에서 passwordHash 변수에 접근할 수 없기 때문에 정보가 숨겨 짐
    // 이때 passwordHash에 접근하는 메서드 역시 생성자 내부에 정의되어야 함
    // 메서드 정의가 생성자 내부에 있으면, 인스턴스 메서드로 생성된다는 점을 기억(메모리 낭비)
    declare function hash(text: string): number;
     
    class PasswordChecker {
      checkPassword: (password: string) => boolean;
      constructor(passwordHash: number) {
        this.checkPassword = (password: string) => {
          return hash(password) === passwordHash;
        };
      }
    }
     
    const checker = new PasswordChecker(hash('s3cret'));
    checker.checkPassword('s3cret'); // true
  4. 비공개 필드 사용

    • 접두사 #
    • 타입 체크와 런타임 모두에서 비공개
    • 클래스 외부에서는 접근할 수 없지만, 클래스 메서드나 동일 클래스의 개별 인스턴스끼리는 접근이 가능

아이템 57: 소스맵을 사용하여 TS 디버깅하기

  1. 디버거는 런타임에 동작하며, 현재 동작하는 코드가 어떤 과정을 거쳤는지 모름

  2. 디버깅 문제를 해결하기 위해 브라우저는 소스맵(source map) 기능을 제공

    • 변환된 코드의 위치와 심벌들을 원본 코드의 원래 위치와 심벌들로 매핑함
  3. TS의 소스맵 활성화

        // tsconfig.json
        // 각 .ts 파일에 대해서 .js와 .js.map 두 개의 파일을 생성
        {
          "compilerOptions": {
            "sourceMap": true
          }
        }
  4. 소스맵에 대해 알아야 할 사항들

    • TS와 함께 번들러나 압축기를 사용하고 있다면, 번들러나 압축기가 각자의 소스맵을 생성. 이상적인 디버깅을 위해서는 생성된 JS가 아닌 원본 TS 소스로 매핑되도록 해야 함
    • 상용 환경에 소스맵이 유출되고 있는지 확인해야 함. 소스맵에 원본 코드의 인라인 복사본이 포함되어 있다면 공개해서는 안될 내용이 들어있을 수 있음

아이템 58: 모던 JS로 작성하기

  1. TS의 컴파일러를 JS의 ‘트랜스파일러’로 사용

    • TS는 JS의 상위집합이므로 TS를 JS로 컴파일할 수 있음
  2. ECMAScript 모듈 사용

    • ES2015에 등장한 importexport를 사용하는 모듈이 표준이 되었음
  3. 프로토타입 대신 클래스 사용

    class Person {
      first: string;
      last: string;
     
      constructor(first: string, last: string) {
        this.first = first;
        this.last = last;
      }
     
      getName() {
        return this.first + ' ' + this.last;
      }
    }
     
    const marie = new Person('Marie', 'Curie');
    const personName = marie.getName();
  4. var 대신 let / const 사용

    • 스코프 문제 피하기
    • 함수 선언문 대신 함수 표현식을 사용하여 호이스팅 문제 피하기
  5. for(;;) 대신 for-of 또는 배열 메서드 사용

    • for-of 루프는 코드가 짧고 인덱스 변수를 사용하지 않아 실수를 줄일 수 있음
    • 인덱스 변수가 필요한 경우엔 forEach 메서드 사용 권장
  6. 함수 표현식보다 화살표 함수 사용

    • 상위 스코프의 this를 유지할 수 있음
    • 코드를 더 직관적이고 간결하게 작성할 수 있음
  7. 단축 객체 표현과 구조 분해 할당 사용

    • 변수와 객체 속성의 이름이 같은 경우

      const x = 1,
        y = 2,
        z = 3;
       
      const pt = { x, y, z };
    • 객체 속성 중 함수를 축약해서 표현하는 방법

      const obj = {
        onClickLong: function (e) {
          // ...
        },
        onClickCompact(e) {
          // ...
        },
      };
    • 객체 구조 분해

      const {
        props: { a, b },
      } = obj;
  8. 함수 매개변수 기본값 사용

    • 기본값을 기반으로 타입 추론이 가능하기 때문에, TS로 마이그레이션 시 매개변수에 타입 구문을 쓰지 않아도 됨
  9. 저수준 프로미스나 콜백 대신 async / await 사용

    • 버그나 실수를 방지할 수 있고, 비동기 코드에 타입 정보가 전달되어 타입 추론을 가능하게 함
  10. 연관 배열에 객체 대신 MapSet사용

    • 인덱스 시그니처 사용 시 : constructor 등의 특정 문자열이 주어지는 경우 예약어로 인식하는 문제

    • Map 사용

      function countWordsMap(text: string) {
        const counts = new Map<string, number>();
       
        for (const word of text.split(/[\s,.]+/)) {
          counts.set(word, 1 + (counts.get(word) || 0));
        }
       
        return counts;
      }
  11. TS에 use strict 넣지 않기

    • 타입스크립트는 기본적으로 'use strict'를 사용
    • alwaysStrict 또는 strict 컴파일러 옵션 설정 권장
  12. TC39나 타입스크립트 릴리즈 노트를 통해 최신 기능 확인 가능


아이템 59: TS도입 전, @ts-check와 JSDoc 시도

  1. @ts-check 지시자를 사용하여 타입 체커가 파일을 분석하고, 발견된 오류를 보고하도록 지시할 수 있음

    • 매우 느슨한 수준으로 타입 체크를 수행
    • 타입 불일치나 함수의 매개변수 개수 불일치 등
  2. 선언되지 않은 전역 변수

    • 숨어 있는 변수라면 변수를 제대로 인식할 수 있게 별도로 타입 선언 파일을 만들기

      // @ts-check
      console.log(user.firstName);
       
      // types.d.ts
      interface UserData {
        firstName: string;
        lastName: string;
      }
       
      declare let user: UserData;
       
      // 선언 파일을 찾지 못하는 경우 ‘트리플 슬래시’ 참조를 사용하여 명시적으로 import
      // @ts-check
      // <reference path="./types.d.ts" />
      console.log(user.firstName); // 정상
  3. 알 수 없는 라이브러리

    • 서드파티 라이브러리의 타입 정보
    • @types/xxx 설치하기
  4. DOM 문제

    // @ts-check
    const ageEl = /** @type {HTMLInputElement} */ document.getElementById('age');
    ageEl.value = '12'; // 정상
  5. 부정확한 JSDoc

    • 타입스크립트 언어 서비스는 타입을 추론해서 JSDoc을 자동으로 생성

      // @ts-check
      /**
       * @param {number} val
       */
      function double(val) {
        return 2 * val;
      }

아이템 60: allowJS로 TS와 JS 같이 사용하기

  1. allowJS 옵션

    • 타입 체크와 관련이 없지만, 기존 빌드 과정에 TS 컴파일러를 추가하기 위함
    • 모듈 단위로 TS로 전환하는 과정에서 테스트를 수행하기 위함
  2. 프레임워크 없이 빌드 체인 직접 구성하기

    • outDir 옵션 사용하기

아이템 61: 의존성 관계에 따라 모듈 단위로 전환하기

  1. 의존성 관련 오류 없이 작업하려면, 다른 모듈에 의존하지 않는 최하단 모듈부터 작업을 시작해서 의존성의 최상단에 있는 모듈을 마지막으로 완성해야 함

    • 서드파티 라이브러리 타입 정보를 가장 먼저 해결 (@types/)
    • 외부 API의 타입 정보 추가
  2. 리팩터링은 TS 전환 작업이 완료된 후에 해야 함

  3. 선언되지 않은 클래스 멤버

    • ‘누락된 모든 멤버 추가’ 빠른 수정

      class Greeting {
        greeting: string;
        name: any; // 직접 수정 필요
       
        constructor(name) {
          this.greeting = 'Hello';
          this.name = name;
        }
       
        greet() {
          return this.greeting + ' ' + this.name;
        }
      }
  4. 타입이 바뀌는 값

    // 한번에 객체 생성 또는 타입 단언문 사용
    const state = {};
    state.name = 'New York';
    // 🚨 '{}' 유형에 'name' 속성이 없습니다
    state.capital = 'Albany';
    // 🚨 '{}' 유형에 'capital' 속성이 없습니다
  5. JS에서 JSDoc@ts-check를 사용해 타입 정보를 추가한 상태라면, 타입스크립트로 전환하는 순간 타입 정보가 ‘무효화’된다는 점에 주의

  6. 마지막으로 테스트 코드를 TS로 전환


아이템 62: 마이그레이션을 위해 noImplicitAny 설정

  1. noImplicitAny 설정을 통해 타입 선언과 관련된 실제 오류를 드러낼 수 있음
  2. 최종적으로 가장 강력한 설정은 strict: true

참고