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

정태호·2023년 8월 3일
0

타입스크립트

목록 보기
7/13
post-thumbnail

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

자바스크립트(그리고 타입스크립트)에서는 함수 문장(statement)과 함수 표현(expression)을 다르게 인식한다.

function rollDice1(sides: number): number { /* ... */ } // 문장
const rollDice2 = function(sides: number): number { /* ... */ } // 표현식
const rollDice3 = (sides: number): number => { /* ... */ } // 표현식

타입스크립트에서는 함수 표현식을 사용하는 것이 좋다.

그 이유를 알아보자.

함수의 매개변수 부터 반환값까지 전체를 함수 타입으로 선언하여 함수 표현식에 재사용할 수 있다.

type Add = (num: number) => number;
const add: Add = num => { /* ... */ }

불필요한 타입 선언 코드 반복을 줄인다.

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;

공통 콜백 함수를 위한 타입 선언을 제공할 수 있다.

라이브러리는 공통 함수 시그니처를 타입으로 제공하여, 공통 콜백 함수를 위한 타입 선언을 제공한다.

리액트는 함수의 매개변수에 명시하는 MouseEvent 타입 대신에, 함수 전체에 적용할 수 있는 MouseEventHandler 타입을 제공한다.

만약 다른 함수의 시그니처를 참조하려면 typeof fn 을 사용하면 된다.

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. 타입과 인터페이스 차이점 알기

타입스크립트에서 명명된 타입(named type)을 정의하는 방법으로 typeinterface가 있다.

대부분의 경우 타입과 인터페이스를 아무거나 사용해도 되지만 둘 사이에 존재하는 차이를 분명하게 알고, 같은 상황에서는 동일한 방법으로 타입을 정의해 일관성을 유지할 필요가 있다.

공통점

타입 상태에는 차이가 없다.

type TPerson = {
  name: string,
}
interface IPerson {
  name: string;
}
// 두 타입 모두 상태가 동일하다.
// 두 타입이 지정된 변수에 알려진 객체 리터럴을 할당하려고 하면 동일한 방법으로 타입 체크를 진행한다.

인덱스 시그니처를 사용할 수 있다.

type TDict = { [key: string]: string };

interface IDict {
  [key: string]: string;
}

함수 타입도 정의 가능하다.

type TFn = (x: number) => string;
interface IFn {
  (x: number): 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; };

클래스를 구현(implements)할 때, 둘 다 사용 가능

class StateT implements TState {
  name: string = '';
  capital: string = '';
}

class StateI implements IState {
  name: string = '';
  capital: string = '';
}

차이점

유니온 타입은 있지만 유니온 인터페이스 개념은 없다.

인터페이스는 유니온 타입 처럼 복잡한 타입을 확장하지 못한다.
복잡한 타입을 확장하고 싶다면 타입& 를 사용해야한다.

인터페이스로 튜플 타입을 완벽하게 구현할 수 없다.

type Pair = [number, number]

interface Tuple {
  0: number;
  1: number;
  length: 2;
}

인터페이스로 튜플과 비슷하게 구현하면 concat 같은 메서드들을 사용할 수 없다.

인터페이스는 보강(argument)이 가능하다.

interface Person {
  name: string;
}

interface Person {
  age: number;
}

const user: Person = { name: "jth", age: 25 };

위 예제처럼 속성을 확장하는 것을 선언 병합 이라고 한다.

병합은 선언처럼 일반적인 코드이기 때문에 언제든지 가능하다. 프로퍼티가 추가되는 것을 원하지 않는다면 인터페이스 대신 타입을 사용해야 된다.

요약

  • 프로젝트에서 어떤 문법을 사용할지 결정할 때 한 가지 일관된 스타일을 확립하고, 보강 기법이 필요한지 고려해야 한다.
  • 즉, 복잡한 타입이라면 타입 별칭. 둘 다 사용할 수 있다면 일관성보강의 관점에서 고려하자.

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

타입에서도 같은 코드를 반복하지 말라는 DRY(don't repear yourself) 원칙을 지키자.

1. 타입에 이름을 붙이자

interface Point2D{
  x: number;
  y: number;
}
function distance(a: Point2D, b: Point2D) { /* ... */ }

2. 타입 확장

  • 인터페이스는 extends
  • 유니온 타입에 속성 추가는 인터섹션 연산자 & 를 사용
interface Person {
  name: string;
}
interface User extends Person {
  age: number;
}

type PersonWithAge = Person & { age: number };

3. 매핑된 타입 사용

interface Person {
  name: string;
  age: number;
  weight: number;
}

type TopPerson = {
  userId: Person['name'];
  age: Person['age'];
  weight: Person['weight'];
}

type TopPerson = {
  [k in 'name' | 'age' | 'weight']: Person[k] // 매핑된 타입
}

type TopPerson = Pick<Person, 'name' | 'age' | 'weight'>; // 제너릭 타입

매핑된 타입은 배열의 필드를 루프 도는 것과 같은 방식이다. 이 패턴은 표준 라이브러리에서도 찾을 수 있으며 Pick 이라 한다.

type Pick<T,K> = { [k in K]: T[k] };

4. 태그된 유니온의 태그 타입 꺼내기 (인덱싱)

다음처럼 태그된 유니온에서 type 속성의 타입을 꺼내고 싶은 경우에 반복이 발생할 수 있다

interface SaveAction {
  type: 'save';
  // ...
}
interface LoadAction {
  type: 'load';
  // ...
}
type Action = SaveAction | LoadAction; // 태그된 유니온
type ActionType = 'save' | 'load'; // 타입의 반복 !

이 경우 Action 유니온을 인덱싱 하여 타입 반복 없이 ActionType 을 정의할 수 있다.

type ActionType = Action['type']; // 타입은  'save' | 'load'

Action 유니온에 타입을 더 추가하면 ActionType은 자동적으로 그 타입을 포함한다. ActionType은 Pick을 사용하여 얻게 되는, type 속성을 가지는 인터페이스와는 다르다.

type ActionRec = Pick<Action, 'type'>; // {type: "save":"load"}

5. 이미 선언된 타입에서 대부분이 선택적 필드가 되는 새로운 타입을 만드는 경우 (매핑된 타입과 keyof)

다음은 인스턴스가 생성되고 난 다음 프로퍼티가 업데이트 되는 클래스를 정의하는 경우다.
이 때 업데이트시 대부분의 타입들이 선택적 필드가 된다.

interface Options {
  width: number;
  height: number;
  color: string;
}
interface OptionsUpdate { // 기존의 Options타입과 동일하면서 대부분이 선택적 필드이다.
  width?: number;
  height?: number;
  color?: string;
}
class UIWidget {
  constructor (init: Options) { /* */ }
  update(options: OptionsUpdate)  { /* */ }
}

매핑된 타입과 keyof 를 사용하면 Options 로부터 OptionsUpdate 를 만들 수 있다.

type OptionsUpdate = { [k in keyof Options]?: Options[k] };

keyof 는 타입을 받아서 속성 타입의 유니온 을 반환한다.

type OptionsKeys = keyof Options; // 'width' | 'height' || 'color'

매핑된 타입([k in keyof Options])은 순회하며 k 값에 해당하는 속성이 있는지 찾고 ?는 각 속성을 선택적으로 만드는데 이 패턴도 표준 라이브러리에 Partial으로 포함되어 있다.

class UIWidget {
  constructor (init: Options) { /* */ }
  update(options: Partial<Options>)  { /* */ }
}

6. 값의 형태에 해당하는 타입 정의하고 싶은 경우 (typeof)

const INIT_OPTIONS = {
  width: 654,
  height: 480,
  color: '#00FF00',
}

type Options = typeof INIT_OPTIONS;

/**
 * 다음과 동일하다.
 * interface Options{
 *  width: number;
 *  height: number;
 *  color: string;
 * }
 * /

여기서 사용된 typeof 는 런타임 연산자가 아니라 타입스크립트 단계에서 연산되어 강력한 타입 표현이 가능하다.

값으로부터 타입을 만들어 낼 때는 선언 순서에 주의해야한다. 타입 정의를 먼저 하고 값이 그 타입에 할당 가능하다고 선언하는 것이 좋다. 그렇게 해야 타입이 더 명확해지고 예상하기 어려운 타입 변동을 방지할 수 있다.

6. 함수나 메서드 반환 값에 명명된 타입 만들기

  • 표준 라이브러리에 ReturnType 제너릭이 있다.
function getUserInfo(userId: string){
  return{
    userId,
    name,
    age,
    height,
};
  
type UserInfo = ReturnType<typeof getUserInfo>;

요약

  • DRY 원칙을 타입에도 최대한 적용하자!
  • 타입들 간 매핑을 위해 타입스크립트가 제공한 도구들을 공부하면 좋다. (keyof, typeof, 인덱싱, 매핑된 타입)
  • 제너릭 타입은 타입을 위한 함수와 같다. 타입 반복 대신 제너릭 타입을 사용해 타입들 간에 매핑을 하자. 제너릭 타입 제한은 extends 사용
  • 표준 라이브러리에 정의된 제너릭 타입에 익숙해지자!

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

타입스크립트에서는 타입에 인덱스 시그니처를 명시하여 유연하게 매핑을 표현할 수 있다.

type Rocket = {[property: string]: string}

인덱스 시그니처의 단점

  • 잘못된 키를 포함해 모든 키를 허용한다. (오타 잡기가 불가능)
  • 특정 키가 필요하지 않아 {}도 유요한 Rocket 타입이 된다.
  • 키마다 다른 타입을 가질 수 없다.
  • 타입스크립트 언어 서비스의 도움을 받을 수 없다.

인덱스 시그니처는 동적 데이터를 표현할 때 사용한다. 그 외에는 부정확하므로 더 나은 방법을 찾는 것이 좋다!

만약 데이터에 A,B,C,D 가 키로 있지만 얼마나 많이 있는지 모르는 경우 인덱스 필드 대신에 선택적 필드 로 모델링하는 것이 좋다.

interface Row = {
  a: number;
  b?: number;
  c?: number;
  d?: number;
}

대안

1. Record 사용

  • Record는 키 타입에 유연성을 제공하는 제너릭 타입이다.
type Vec3D = Record<'x' | 'y' | 'z', number>;
/** 
   Type Vec3D {
      x: number;
      y: number;
      z: number;
  }
*/

2. 매핑된 타입 사용

  • 매핑된 타입은 키마다 별도의 타입을 사용 가능하게 한다.
type Vec3D = {[k in 'x' | 'y' | 'z']: number};

type ABC = {[k in 'a' | 'b' | 'c']: k extends 'b' ? string: number}; //조건부 타입

요약

  • 런타임 때까지 객체의 속성을 알 수 없을 경우에만 인덱스 시그니처를 사용하도록 하자!
  • 안전한 접근을 위해 인덱스 시그니처 값 타입에 undefined를 추가할 수도 있다.
  • 가능하면 정확한 타입을 사용하는 것이 좋다.

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

  • 자바스크립트에서 객체란 키/값 쌍의 모음이다.
  • 객체의 키는 symbol, string만 가능하다. 값은 어떤 것이든 될 수 있음

숫자는 키로 사용할 수 없기 때문에 만약 속성 이름으로 숫자를 사용하려 하면 자바스크립트 런타임은 문자열로 변환한다.

{ 1 : 2, 3 : 4}
{ '1' : 2, '3', : 4}

const x = [1, 2, 3];
Object.keys(x) // ['0', '1', '2']

타입스크립트는 이러한 혼란을 잡기위해 숫자 키를 허용하고, 문자열 키와 다른 것으로 인식한다. 런타임에는 문자열 키로 인식하므로 완전히 가상이라고 할 수 있지만, 타입 체크 시점에 오류를 잡을 수 있어 유용하다.

ArrayLike

  • 배열과 비슷하지만 객체이다.
  • length 프로퍼티를 갖고 배열과 유사한 반복 가능한 객체로 동작한다.
  • 배열 메소드를 사용하고 싶다면 ... 이나 Array.from()을 사용해서 배열로 바꿔줘야 한다.
const arr = [1, 2, 3]; // 배열 -> 숫자로 인덱스할 항목을 지정하는 경우 배열을 사용하는것이 바람직하다.

const arrLike = { // 유사 배열 객체. 키는 여전히 문자열이다.
 '0': 1,
 '1': 2,
 '2': 3,
 length: 3,
}; 

요약

  • 배열은 객체이기 때문에 키는 숫자가 아니라 문자열이다. 인덱스 시그니처로 사용된 number타입은 버그를 잡기 위한 순수 타입스크립트 코드이다.
  • 인덱스 시그니처에 number를 사용하기 보단 Array나 튜플, ArrayLike 타입을 사용하자!

변경 관련된 오류 방지를 위해 readonly 사용하기

어떤 배열에 readonly 접근 제어자를 사용하면 다음과 같은 특징을 가지게 된다.

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

number[]는 readonly number[] 보다 기능이 많기 때문에, readonly number[]의 서브타입이 된다. 따라서 변경 가능한 배열을 readonly 배열에 할당할 수 있다. 하지만 그 반대는 불가능하다.

매개변수를 readonly로 선언하면 다음과 같은 일이 생긴다.

  • 타입스크립트는 매개변수가 함수 내에서 변경이 일어나는지 체크한다.
  • 호출하는 쪽에서 함수가 매개변수를 변경하지 않는다는 보장을 받게 된다.
  • 호출하는 쪽에서 함수에 readonly 배열을 매개변수로 넣을 수도 있다.

const vs readonly

readonly 는 객체를 변경할 수 없다는 뜻이지, 변수에 재할당이 불가능하다는 의미가 아니다.

let arr: readonly number[] = [1, 2, 3];
arr.push(4); // 'readonly number[]' 형식에 'push' 속성이 없습니다.
arr = [1, 2, 3, 4]; // 정상

readonly 는 얕게 동작한다.

만약 객체의 프로퍼티로 readonly 배열이 있더라도 객체 자체는 readonly는 아니다.

const dates: readonly Date[] = [new Date()]; // readonly 배열 
dates.push(new Date()); // readonly Date[] 형식에 push 속성이 없습니다.
dates[0].setFullYear(2022); // 정상 -> Date 객체 자체는 readonly가 아니다.

Readonly 제너릭도 이와 유사하게 얕게 동작한다.

interface Outer {
  inner: {
    x: number;
  }
}

const o: Readonly<Outer> = { inner: { x: 0 } };
o.inner = { x: 1 }; // 오류
o.inner.x = 1; // 정상

타입 별칭을 통해 동작 이해하기

type T = Readonly<Outer>;
// Type T = {
//   readonly inner: {
//     x: number;
//   };
// }

깊은 readonly 타입을 사용하고 싶다면 ts-essentials 라이브러리의 DeepReadonly 제너릭을 사용하면 된다.

요약

  • 함수가 매개변수를 수정하지 않는다면 readonly로 선언하자. 인터페이스를 명확하게 하며, 매개변수가 변경되는 것을 방지한다.
  • readonly를 사용하면 변경하면서 발생하는 오류 방지 및 변경이 발생하는 코드도 쉽게 찾을 수 있다.

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

산덤도를 그리기위한 UI 컴포넌트를 작성하는 경우에 디스플레이와 동작을 제어하기 위한 몇 가지 타입 속성이 포함된 예를 들어보자.

interface ScatterProps {
  // data
  xs: number[];
  xs: number[];

  // display
  xRange: [number, number];
  yRange: [number, number];
  color: string;

  // events
  onClick: (x: number, y: number, index: number) => void;
}

필요할 때만 차트를 다시 그려야 하는 경우, 즉 데이터나 디스플레이 속성이 변경되면 다시 그리지만 이벤트 핸들러가 변경되면 다시 그릴 필요가 없다.

최적화 첫 번째 ( 실패에 닫힌(보수적) 접근법 )

  • 오류 발생 시에 적극적으로 대처하는 방향을 의미한다.
function shouldUpdate(oldProps: ScatterProps, newProps: ScatterProps){
  let k: keyof Scatterprops;
  for(k in oldProps){
    if(oldProps[k] !== newProps[k]){
      if(k !== 'onClick')
        return true;
    }
  }
  return false;
}

위 예제에서 만약 새로운 속성이 추가되면 shouldUpdate 함수는 값이 변경될 때마다 차트를 다시 그릴 것이다.

이 접근법을 이용하면 차트가 정확하지만 너무 자주 그려질 가능성이 있다.

최적화 두 번째 ( 실패에 열린 접근법 )

function shouldUpdate(oldProps: ScatterProps, newProps: ScatterProps){
  return(
  	oldProps.xs !== newProps.xs ||
    oldProps.ys !== newProps.ys ||
    oldProps.xRange !== newProps.xRange ||
    oldProps.yRange !== newProps.yRange ||
    oldProps.color !== newProps.color ||
  );
}

차트를 불필요하게 다시 그리는 단점은 해결했지만 실제로 차트를 다시 그려야 할 경우에 누락될 수 있다.

타입 체커가 동작하도록 개선하기

shouldUpdate 함수는 ScatterProps 타입에 의존적이다. 따라서 둘의 관계를 연결시켜 동기화 해주어야 한다. 그래야 ScatterProps 타입이 업데이트 되어 변경되었을 때에 타입스크립트가 타입체크를 통해 shouldUpdate 또한 업데이트가 필요하다고 알릴 수 있다.

매핑된 타입객체를 사용해서 동기화 할 수 있다.

const REQUIRES_UPDATE: {[k in keyof ScatterProps]: boolean} { 
  // 매핑된 타입으로 REQUIRES_UPDATE 가 ScatterProps과 동일한 속성을 가져야 한다는 정보 제공
  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;
  }
}

매핑된 타입은 한 객체가 또 다른 객체와 정확히 같은 속성을 가지게 할 때 이상적이다. 위 예제처럼 매핑된 타입을 사용해 타입스크립트가 코드에 제약을 강제하도록 할 수 있다.

요약

  • 매핑된 타입을 사용해 관련된 값과 타입을 동기화하자.
  • 인터페이스에 새로운 속성을 추가한다면, 선택을 강제하도록 매핑된 타입을 고려해야 한다.
profile
주니어 프론트엔드 개발자가 되고 싶습니다!

0개의 댓글