221024 TIL | 이펙티브 타입스크립트_#5 함수 표현식에 타입 적용하기 등

아이템12 ~ 13까지 정리

  • 아이템 12 : 타입 단언보다 타입 선언 사용하기
  • 아이템 13 : 타입과 인터페이스의 차이점 알기

함수 표현식에 타입 적용하기

  • 자바스크립트와 마찬가지로 타입스크립트 또한 문장(statement)과 함수 표현식(expression)을 다르게 인식한다.
function rollDice1(sides: number): number { /* ... */ }  // 문장
const rollDice2 = function(sides: number): number { /* ... */ };  // 문장
const rollDice3 = (sides: number): number => { /* ... */ };  // 표현식
  • 타입스크립트에서는 함수 표현식을 사용하는 것이 좋다.
    • 매개변수부터 반환값까지 전체를 함수 타입으로 선언하여 함수 표현식에 재사용 할 수 있다.
type DiceRollFn = (sides: number) => number;
const rollDice: DiceRollFn = sides => { /* ... */ };

함수 타입의 장점

  1. 불필요한 코드의 반복을 줄인다.
    • 타입 적용 전
     function add(a: number, b: number) { return a + b; }
     function sub(a: number, b: number) { return a - b; }
     function mul(a: number, b: number) { return a * b; }
     function div(a: number, b: number) { return a / b; }
    
    • 타입 적용 후
     type BinaryFn = (a: number, b: number) => number;
     const add: BinaryFn = (a, b) => a + b;
     const sub: BinaryFn = (a, b) => a - b;
     const mul: BinaryFn = (a, b) => a * b;
     const div: BinaryFn = (a, b) => a / b;
    
  2. 라이브러리를 만든다면 공통 함수 시그니처를 타입으로 제공할 수 있다.
    • ex ) 리액트에서 MouseEvent 타입 대신 함수 전체에 적용할 수 있는 MouseEventHandler 타입을 제공하는 것처럼

다른 함수의 시그니처를 참조하고 싶다면

  • 다른 함수의 시그니처를 참조하려면 typeof fn을 사용한다.
  • 예로 들어 특정 리소스에 HTTP 요청을 보낸 후 response.json()을 이용해 응답 데이터를 추출하는 코드가 있을 때,
async function getQuote() {
  const response = await fetch('/quote?by=Mark+Twain');
  const quote = await response.json();
  return quote;
}
// {
//   "quote": "If you tell the truth, you don't have to remember anything.",
//   "source": "notebook",
//   "date": "1894"
// }
  • 만약 /quote 가 존재하지 않는 API라면 404 에러가 뜬다. 또한 응답은 JSON 형식이 아닐 수 있다.
  • 또한 fetch가 실패하면 거절된 프로미스를 응답하지 않기 때문에 상태 체크를 수행해줄 checkFetch 함수를 만들어보자.
// lib.dom.d.ts에 있는 fetch 함수를 참조한다.
declare function fetch(
  input: RequestInfo, init?: RequestInit
): Promise<Response>;
  • typeof fetch 를 사용하여 타입스크립트가 input과 init 타입을 추론할 수 있도록 한다.
  • 시그니처 참조 또한 반환 타입을 보장하기 때문에 만약 throw 대신 return을 사용했다면 타입스크립트는 실수를 잡아낸다.
const checkedFetch: typeof fetch = async (input, init) => {
  const response = await fetch(input, init);
  if (!response.ok) {
    throw new Error('Request failed: ' + response.status);
		// return new Error('Request failed: ' + response.status); 라고 쓴다면?
		// Promise<Response | Error> 형식을 할당할 수 없다는 오류가 뜬다.
  }
  return response;
}

타입과 인터페이스의 차이점 알기

  • 타입스크립트에서 named type을 정의하는 방법은 두 가지가 있다.
    1. type

       // 타입 선언 예시
       type TState = {
         name: string;
         capital: string;
       }
      
    2. interface

       // 인터페이스 선언 예시
       interface IState {
         name: string;
         capital: string;
       }
      
    3. class (값으로도 쓰일 수 있는 런타임 개념이다. 아이템 8 참고)

인터페이스 선언과 타입의 비슷한 점

  • 추가 속성과 함께 할당하면 오류가 발생한다.
  • 인덱스 시그니처를 사용할 수 있다.
    • 뭐였더라 ? 타입스크립트가 추가적인 속성을 예상할 수 있도록 하는 방법
  • 함수 타입 지정이 가능하다.

      // 함수 타입 지정
      type TFn = (x: number) => string;
      interface IFn {
        (x: number): string;
      }
        
      const toStrT: TFn = x => '' + x;  // OK
      const toStrI: IFn = x => '' + x;  // OK
        
      // 자바스크립트에서 함수는 호출 가능한 객체이기 때문에 이런 문법도 가능하다.
      type TFnWithProperties = {
        (x: number): number;
        prop: string;
      }
      interface IFnWithProperties {
        (x: number): number;
        prop: string;
      }
    
  • 타입과 인터페이스는 모두 제너릭을 사용할 수 있다.

      type TPair<T> = {
        first: T;
        second: T;
      }
      interface IPair<T> {
        first: T;
        second: T;
      }
    
  • 인터페이스는 타입을 확장할 수 있고, 타입은 인터페이스를 확장할 수 있다.

      // 이렇게 사용하기 위해서 몇가지 주의사항이 있는데 추후 다루도록 한다.
      interface IStateWithPop extends TState {
        population: number;
      }
      type TStateWithPop = IState & { population: number; };
    
    • IStateWithPop = TStateWithPop
  • 예시처럼 인터페이스는 유니온 같은 복잡한 타입을 확장하지 못 한다.
    • 만약 복잡한 타입을 확장하고 싶다면 타입과 &을 사용해야 한다.
  • 클래스를 구현(implments)할 때는 타입과 인터페이스 둘 다 사용할 수 있다.

타입과 인터페이스의 다른 점

  • 앞서 언급한 것처럼 유니온 타입은 있지만 유니온 인터페이스라는 개념은 없다.
  • 인터페이스는 타입을 확장할 수 있지만, 유니온은 확장할 수 없다.

만약 유니온 타입을 확장하고 싶다면?

  • Input | Output는 별도의 타입이며 이 둘의 하나의 변수명으로 매핑하는 VariableMap 인터페이스를 만들 수 있다.
type Input = { /* ... */ };
type Output = { /* ... */ };
interface VariableMap {
  [name: string]: Input | Output;
}
  • 또는 유니온 타입에 name 속성을 붙인 타입을 만들 수도 있다.
type NamedVariable = (Input | Output) & { name: string };

→ 이 타입은 인터페이스로 표현할 수 없다.

  • type 키워드는 일반적으로 interface보다 쓰임새가 많다.
    • 유니온이 될 수도 있고 매핑된 타입 또는 조건부 타입같은 고급 기능에 활용이 되기도 한다. (아직 어떻게 쓰는지 모르겠지만..)
  • 튜플, 배열 타입도 type을 이용하는 것이 좋다.
    • interface도 구현할 수 있지만 튜플에서 사용할 수 있는 concat 같은 메서드를 사용할 수 없게 된다.
      interface Tuple {
        0: number;
        1: number;
        length: 2;
      }
      const t: Tuple = [10, 20];  // OK
    

type만 쓰면 되지 interface는 왜 쓰는 건가요?

  • 인터페이스에는 타입에 없는 몇 가지 기능이 있다
  • 인터페이스는 보강(augment)이 가능하다.
interface IState {
  name: string;
  capital: string;
}
interface IState {
  population: number;
}
const wyoming: IState = {
  name: 'Wyoming',
  capital: 'Cheyenne',
  population: 500_000
};  // OK
  • 이렇게 속성을 확장하는 방법을 선언 병합(declaration merging)이라고 한다.
    • 주로 타입 선언 파일에서 사용한다.
  • 따라서 타입 선언 파일을 작성할 때는 선언 병합을 지원하기 위해 반드시 인터페이스를 사용한다.
  • 타입은 기존 타입에 추가적인 보강이 없는 경우에만 사용한다.

타입과 인터페이스 어떤 상황에서 어떻게 사용하면 좋을까?

  • 어떤 API에 대한 타입 선언을 작성해야 한다면? → 인터페이스
    • API가 변경될 때 사용자가 인터페이스를 통해 새로운 필드를 병합할 수 있어 유용하다.
  • 프로젝트 내부적으로 사용되는 타입 → 타입
    • 내부적으로 사용되는 타입에 선언 병합이 발생하는 것은 잘못된 설계이다.

출처

  • 이펙티브 타입스크립트