[이펙티브 타입스크립트] 2장 타입스크립트의 타입 시스템
Prog. Langs & Tools/TypeScript

[이펙티브 타입스크립트] 2장 타입스크립트의 타입 시스템

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

편집기를 사용하면 어떻게 타입 시스템이 동작하는지, 그리고 타입스크립트가 어떻게 타입을 추론하는지 개념을 잡을 수 있다.

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

런타입에 모든 변수는 자바스크립트 세상의 값으로부터 정해지는 각자의 고유의 값을 가진다. 코드가 실행되기 전, 타입스크립트가 오류를 체크하는 순간에는 타입을 가지고 있다.

가장 작은 집합은 아무 값도 포함하지 않는 공집합이며, 타입스크립트에서는 never 타입이다. never 타입으로 선언된 변수의 범위는 공집합이기 때문에 아무런 값도 할당할 수 없다.

그 다음으로 작은 집합은 한 가지 값만 포함하는 타입이다. 이들은 타입 스크립트에서 유닛(unit) 타입이라고도 불리는 리터럴(literal) 타입이다.

두 개 혹은 세 개로 묶으려면 유니온(union) 타입을 사용한다.

아이템 4에서 설명했듯이, 구조적 타이핑 규칙들은 어떠한 값이 다른 속성도 가질 수 있음을 의미한다. 심지어 함수 호출의 매개변수에서도 다른 속성을 가질 수 있다.

interface Person {
    name: string;
}

interface Lifespan {
    birth: Date;
    death?: Date;
}

type PersonSpan = Person & Lifespan;

const ps: PersonSpan = {
    name: 'Alan Turing',
    birth: new Date('1912/06/23'),
    death: new Date('1954/06/07')
}; // 정상

타입 연산자는 인터페이스의 속성이 아닌, 값의 집합(타입의 범위)에 적용된다. 그리고 추가적인 속성을 가지는 값도 여전히 그 타입에 속한다. 따라서 당연히 위의 세 가지보다 더 많은 속성을 가지는 값도 PersonSpan 타입에 속한다.

규칙이 속성에 대한 인터섹션에 관해서는 맞지만, 두 인터페이스의 유니온에서는 그렇지 않다.

type K = keyof (Person | Lifespan); // 타입이 never

// keyof (A&B) = (keyof A) | (keyof B)
// keyof (A|B) = (keyof A) & (keyof B)

조금 더 일반적으로 PersonSpan 타입을 선언하는 방법은 extends 키워드를 쓰는 것이다.

interface Person {
    name: string;
}

interface PersonSpan extends Person {
    birth: Date;
    death?: Date;
}

타입이 집합이라는 관점에서 extends의 의미는 ~에 할당 가능한과 비슷하게, ~의 부분 집합이라는 의미로 받아들일 수 있다.

extends 키워드는 제너릭 타입에서 한정자로도 쓰이며, 이 문맥에서는 ~의 부분 집합을 의미하기도 한다.

function getKey<K extends string>(val: any, key: K) {
    // ...
}

string을 상속한다는 의미를 객체 상속의 관점이 아닌, 집합의 관점으로 생각해 보면 쉽게 이해할 수 있다. string의 부분 집합 범위를 가지는 어떠한 타입이 된다. 이 타입은 string 리터럴 타입, string 리터럴 타입의 유니온, string 자신을 포함한다.

getKey({}, 'x'); // 정상
getKey({}, Math.random() < 0.5 ? 'a' : 'b'); // 정상, 'a'|'b'는 string을 상속
getKey({}, document.title); // 정상
getKey({}, 12); // ~~ '12' 형식의 인수는 'string' 형식의 매개변수에 할당될 수 없다.

마지막 오류에서 할당될 수 없다는 상속의 관점에서 상속할 수 없다로 바꿀 수 있고, 두 표현 모두 ~의 부분집합의 의미로 받아들인다면 문제가 없다. 이렇게 할당과 상속의 관점을 전환해 보면, 객체의 키 타입을 반환하는 key T를 이해하기 수월하다.

interface Point {
    x: number;
    y: number;
}
type PointKeys = keyof Point; // 타입은 'x' | 'y'

function sortBy<K extends keyof T, T>(vals: T[], key: K): T[] {
    // ...
}
const pts: Point[] = [{x: 1, y: 1}, {x: 2, y: 0}];
sortBy(pts, 'x'); // 정상, 'x'는 'x'|'y'를 상속
sortBy(pts, 'y'); // 정상, 'y'는 'x'|'y'를 상속
sortBy(pts, Math.random() < 0.5 ? 'x' | 'y'); // 정상, 'x'|'y'는 'x'|'y'를 상속
sortBy(pts, 'z'); // ~~ 'z' 형식의 인수는 'x'|'y' 형식의 매개변수에 할당될 수 없다.

타입이 집합이라는 관점은 배열과 튜플의 관계 역시 명확하게 만든다. 예를 들면,

const list = [1,2]; // 타입은 number[]
const tuple: [number, number] = list; // ~~~~ 'number[]' 타입은 '[number, number]' 타입의 0, 1 속성밖에 없습니다.

정리하면,

  • 타입을 값의 집합으로 생각하면 이해하기 편하다.
  • 타입스크립트 타입은 엄격한 상속 관계가 아니라 겹쳐지는 집합으로 표현된다.

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

타입스크립트 심벌(symbol)은 타입 공간이나 값 공간 중의 한 곳에 존재한다. 심벌은 이름이 같더라도 속하는 공간에 따라 다른 것을 나타낼 수 있기 때문에 혼란스러울 수 있다.

interface Cylinder {
  radius: number;
  height: number;
}

const Cylinder = { radius: number, height: number } => ({radius, height});

interface Cylinder에서 Cylinder는 타입으로 쓰인다. const Cylinder에서 Cylinder와 이름은 같지만 값으로 쓰이며, 서로 아무런 관련이 앖다. 상황에 따라서 Cylinder는 타입으로 쓰일 수도 있고, 값으로 쓰일 수도 있다.

function calculateVolume(shape: unknown) {
  if (shape instanceof Cylinder) {
        shape.radius // ~~~ '{}' 형식에 'radius' 속성이 없음
    }
}

아마 instanceof를 통해 shape가 Cylinder 타입인지 체크하려고 했을 것이다. 그러나 instanceof는 자바스크립트 런타임 연산자이고, 값에 대해서 연산을 한다. 따라서 instanceof Cylinder는 타입이 아니라 함수를 참조한다.

연산자 중에서 타입에서 쓰일 때와 값에서 쓰일 때 다른 기능을 하는 것들이 있다. 그 예 중 하나가 typeof이다.

const p = Person = { first: 'Jane', last: 'Jacob' };
function email(p: Person, subject: string, body: string): Response {
// ...
}

type T1 = typeof p; // 타입은 Person
type T2 = typeof email; // 타입은 (p: Person, subject: string, body: string) => Response

const v1 = typeof p; // 값은 'object'
const v2 = typeof email; // 값은 'function'

타입의 관점에서 typeof는 값을 읽어서 타입스크립트 타입을 반환한다. 값의 관점에서 typeof는 자바스크립트 런타임의 typeof 연산자가 된다. 값 공간의 typeof는 대상 심벌의 런타임 타입을 가리키는 문자열을 반환하며, 타입스크립트 타입과는 다르다.

또한 두 공간(값과 타입)에서 다른 의미로 쓰이는 코드 패턴이 몇 가지가 더 있다.

  • 값으로 쓰이는 this는 자바스크립트 this 키워드이다. 타입으로 쓰이는 this는 일명 다형성(polymorphic) this 라고 불리는 this의 타입스크립트 타입이다. 서브클래스의 메서드 체인을 구현할 때 유용하다.
  • extends는 서브클래스 (class A extends B) 또는 서브타입 (interface A extends B) 또는 제너릭 타입의 한정자 (Generic/)를 정의할 수 있다.

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

타입스크립트에서 변수에 값을 할당하고 타입을 부여하는 방법은 두 가지 이다.

interface Person { name: string };

const alice: Person = { name: 'Alice' }; // 타입은 Person
const bob = { name: 'Bob' } as Person; // 타입은 Person

첫 번째 방법은 타입 선언 을 붙여서 그 값이 선언된 타입임을 명시한다. 두 번째 방법은 타입 단언 을 수행한다. 그러면 타입스크립트가 추론한 타입이 있더라도 Person 타입으로 간주한다.

const alice: Person = {}; // ~~~ 'Person' 유형에 필요한 'name' 속성이 '{}' 유형에 없습니다
const bob = {} as Person; // 오류 없음

타입 단언보다 타입 선언을 사용하는 것이 낫다. 타입 선언의 경우 할당되는 값이 해당 인터페이스를 만족하는지 검사한다. 반면 타입 단언은 강제로 타입을 지정했으니 타입 체커에게 오류를 무시하라고 한다. 안정성 체크가 되는 타입 선언을 사용하도록 하자.

타입단언이 꼭 필요한 경우도 있다. 타입 단언은 타입 체커가 추론한 타입보다 우리가 판단하는 타입이 더 정확할 때 의미가 있다.

document.querySelector('#myButton').addEventListener('click', e => {
    e.currentTarget // 타입은 EventTarget
    const button = e.currentTarget as HTMLButtonElement;
    button  // 타입은 HTMLButtonElement
});

예를 들어 아래의 예제에서 타입스크립트는 DOM에 접근할 수 없기 때문에 #myButton이 버튼 엘리먼트인지 알지 못한다. 그리고 이벤트의 currentTarget이 같은 버튼이어야 하는 것도 알지 못한다. 우리는 타입스크립트가 알지 못하는 정보를 가지고 있기 때문에 여기서는 타입 단언문을 쓰는 것이 타당하다.

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

자바스크립트는 기본형과 객체 타입을 서로 자유롭게 변환한다. string 기본형에 charAt 같은 메서드를 사용할 때, 자바스크립트 기본형을 String 객체로 래핑(wrap)하고, 메서드를 호출하고, 마지막에 래핑할 객체를 버린다.

타입스크립트는 기본형과 객체 래퍼 타입을 별도로 모델링 한다.

  • string과 String
  • number와 Number
  • boolean과 Boolean
  • symbol과 Symbol
  • bigint와 BigInt

그런데 string을 사용할 때 특히 유의해야 한다. string을 String으로 잘못 타입하기 쉽고, 실수를 하더라도 처음에는 잘 동작하는 것처럼 보이기 때문이다.

function getStringLen(foo: String) {
    return foo.length;
}

getStringLen("hello"); // 정상
getStringLen(new String("hello")); // 정상

그러나 string을 매개변수로 받는 메서드에 String 객체를 전달하는 순간 문제가 발생한다.

function isGreeting(phrase: String) {
  return [
        'hello',
        'good day'
    ].includes(phrase); // 'String' 형식의 인수는 'string' 형식의 매개변수에 할당할 수 없습니다.
}

string은 String에 할당할 수 있지만, String은 string에 할당할 수 없다.

const a: String = "primitive";
const n: Number = 12;
const b: Boolean = true;

런타임의 값은 객체가 아니라 기본형이다. 그러나 기본형 타입은 객체 래퍼에 할당할 수 있기 때문에 타입스크립트는 기본형 타입을 객체 래퍼에 할당하는 선언을 허용한다.

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

타입이 명시된 변수에 객체 리터럴을 할당할 때 타입스크립트는 해당 타입의 속성이 있는지, 그리고 '그 외의 속성은 없는지' 확인한다.

interface Room {
    numDoors: number;
    ceilingHeightFt: number;
}

// 첫 번째 예제
const r: Room = {
    numDoors: 1,
    ceilingHeightFt: 10,
    elephant: 'present' // 개체 리터럴은 알려진 속성만 저장할 수 있으며 'Room' 형식에 'elephant'가 없습니다.
}

// 두 번째 예제
const obj = {
    numDoors: 1,
    ceilingHeightFt: 10,
    elephant: 'present'
};

const r: Room = obj; // 정상

첫 번째 예제에서는, 구조적 타입 시스템에서 발생할 수 있는 중요한 종류의 오류를 잡을 수 있도록 '잉여 속성 체크'라는 과정이 수행되었다. 그러나 잉여 속성 체크 역시 조건에 따라 동작하지 않는다는 한계가 있고, 통상적인 할당 가능 검사와 함께 쓰이면 구조적 타이핑이 무엇인지 혼란스러워 질 수 있다. 잉여 속성 체크는 할당 가능 검사와 별도의 과정이다.

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

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

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

타입스크립트에서는 함수 표현식을 사용하는 것이 좋다. 함수의 매개변수부터 반환값까지 전체를 함수 타입으로 선언하여 함수표현식에 재사용할 수 있다는 장점이 있기 때문이다.

함수 타입의 선언은 불필요한 코드의 반복을 줄인다. 사칙연산을 하는 함수 네 개는 다음과 같이 작성할 수 있다.

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 타입을 제공한다. 만약 라이브러리를 직접 만들고 있다면 공통 콜백 함수를 위한 타입 선언을 제공하는 것이 좋다.

시그니처가 일치하는 다른 함수가 있을 때에도 함수 표현식에 타입을 적용해 볼 만 하다. 예를 들어, 웹 브라우저에서 fetch 함수는 특정 리소스에 HTTP 요청을 보낸다. 그리고 response.json() 또는 response.text()를 사용해 응답의 데이터를 추출한다.

만약 존재하지 않는 API를 호출할 경우 404 Not Found가 포함된 내용을 응답한다. 이 때 응답은 JSON 형식이 아닐 수 있다. response.json()은 JSON 형식이 아니라는 새로운 오류 메시지를 담아 거절된(rejected) 프로미스를 반환한다. 호출한 곳에서는 새로운 오류 메시지가 전달되어 실제 오류인 404가 감추어진다.

또한 fetch가 실패하면 거절된 프로미스를 응답하지 않는다는 걸 간과하기 쉽다. 그러니 상태 체크를 수행해 줄 checkedFetch 함수를 작성한다. 참고로 fetch 타입 선언은 lib.dom.d.ts 에 있다.

// fetch 함수
declare function fetch(input: RequestInfo, init?: RequestInit): Promise<Response>;

// checkedFetch 함수
async function checkedFetch(input: RequestInfo, init? RequestInit) {
  const response = await fetch(input, init);
    if (!response.ok) {
        // 비동기 함수 내에서 거절된 프로미스를 반환한다.
        throw new Error('Request failed: ', response.status);
    }
    return response;
}

여기서 checkedFetch 함수를 조금 더 간결하게 써 볼 수도 있다. 함수 문장을 함수 표현식으로 바꾸었고, 함수 전체에 타입(typeof fetch)을 적용했다.

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;
}

타입 구문은 또한 checkedFetch의 반환 타입을 보장하며, fetch와 동일하다. 예를 들어 throw 대신 return을 사용했다면 타입스크립트는 그 실수를 잡아낼 것이다.

함수의 매개변수에 타입 선언을 하는 것보다 함수 표현식 전체 타입을 정의하는 것이 코드도 간결하고 안전하다. 다른 함수의 시그니처와 동일한 타입을 가지는 새 함수를 작성하거나, 동일한 타입 시그니처와 동일한 타입을 가지는 새 함수를 작성하거나, 동일한 타입 시그니처를 가지는 여러 개의 함수를 작성할 때는 매개변수의 타입과 반환 타입을 반복해서 작성하지 말고 함수 전체의 타입 선언을 적용해야 한다.

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

대부분의 경우에는 타입을 사용해도 되고 인터페이스를 사용해도 된다. 그러나 타입과 인터페이스 사이에 존재하는 차이를 분명하게 알고, 같은 상황에서는 동일한 방법으로 명명된 타입을 정의해 일관성을 유지해야 한다.

인덱스 시그니처는 인터페이스와 타입에서 모두 사용할 수 있다.

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

또한 함수 타입도 인터페이스나 타입으로 정의할 수 있다.

type TFn = (x: number) => string;
interface IFn {
    (x: number): string;
}

const toStrT: TFn = x => '' + x; // 정상
const toStrI: IFn = x => '' + x; // 정상

타입 별칭과 인터페이스는 모두 제네릭이 가능하다.

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은 동일하다. 여기서 주의할 점은 인터페이스는 유니온 타입 같은 복잡한 타입을 확장하지는 못한다. 복잡한 타입을 확장하고 싶다면 타입과 &를 사용해야 한다.

클래스를 구현(implements)할 때는, 타입과 인터페이스를 둘 다 사용할 수 있다.

지금까지는 타입과 인터페이스의 비슷한 점들을 살펴보았다. 지금부터는 다른 점들을 살펴보려 한다.

유니온 타입은 있지만, 유니온 인터페이스라는 개념은 없다. 인터페이스는 타입을 확장할 수 있지만 유니온은 할 수 없다.

유니온 타입에 name 속성을 붙인 타입을 만들 수가 있다.

type NamedVariable = { Input | Output } & { name: string };

이 타입은 인터페이스로 표현할 수 없다. type 키워드는 일반적으로 인터페이스보다 쓰임새가 많다.

튜플은 type 키워드로 구분하는 것이 낫다. 인터페이스로 튜플을 비슷하게 구현할 수는 있으나, 튜플에서 사용할 수 있는 concat과 같은 메서드들은 사용할 수 없다.

interface Tuple {
    0: number;
    1: number;
    length: 2;
}
const t: Tuple = [10, 20]; // 정상

반면 인터페이스는 타입에 없는 몇 가지 기능이 있다. 그 중 하나는 보강(argument)이 가능하다는 것이다. State 예제에 population 필드를 추가할 때 보강 기법을 사용할 수 있다.

interface IState {
    name: string;
    capital: string;
}

interface IState {
    population: number;
}

const wyoming: IState = {
    name: 'Wyoming',
    capital: 'Cheyenne',
    population: 500_000
}; // 정상

이 예제처럼 속성을 확장하는 것을 선언 병합(declaration merging)이라고 한다. 선언 병합은 주로 타입 선언 파일에서 사용된다. 따라서 타입 선언 파일을 작성하기 위해서는 반드시 인터페이스를 사용해야 하고 표준을 따라야 한다.

정리하며, 다시 처음 질문으로 돌아가보자. 타입과 인터페이스 중 어떤 것을 사용해야 할까?

  • 복잡한 타입이라면 고민할 것도 없이 타입 별칭을 사용하면 된다.
  • 그러나 타입과 인터페이스, 두 가지 방법으로 모두 표현할 수 있는 간단한 객체 타입이라면 일관성과 보강의 관점에서 고려해 보아야 한다.
    • 일관되게 인터페이스를 사용하는 코드베이스에서 작업하고 있다면 인터페이스를 사용하고,
    • 일관되게 타입을 사용 중이라면 타입을 사용하면 된다.
  • 아직 스타일이 확립되지 않은 프로젝트라면, 향후 보강 가능성이 있을지 생각해 보아야 한다.
    • 어떤 API에 대한 타입 선언을 작성해야 한다면 인터페이스를 사용하는 것이 좋다.
      • API가 변경될 때 사용자가 인터페이스를 통해 새로운 필드를 병합할 수 있어 유용하기 때문이다.
    • 프로젝트 내부적으로 사용되는 타입에 선언 병합이 발생하는 것은 잘못된 설계이다. 이럴 때는 타입을 사용해야 한다.

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

같은 코드를 반복하지 말라는 DRY(Don't Repeat Yourself)를 기억할 것이다. 반복된 코드를 제거하는 부분은 타입에서도 예외가 아니다. 인터페이스의 경우는 상속을 통해서 중복을 제거할 수 있고, 함수의 경우는 타입 시그니처 등을 통해서 중복된 타입 선언을 줄일 수 있다.

어떤 타입이 있고 이 타입의 부분만 표현하는 경우를 한 번 생각해 보자.

interface State {
    userId: string;
    pageTitle: string;
    recentFiles: string[];
    pageContents: string;
}

interface TopNavState {
    userId: string;
    pageTitle: string;
    recentFiles: string[];
}

TopNavState를 확장하여 State를 구성하기보다, State의 부분 집합으로 TopNavState를 정의하는 것이 바람직해 보인다. 이 방법이 전체 앱의 상태를 하나의 인터페이스로 유지할 수 있게 해준다.

State를 인덱싱하여 속성의 타입에서 중복을 제거할 수 있다.

type TopNavState = {
    userId: State['userId'];
    pageTitle: State['pageTitle'];
    recentFiles: State['recentFiles'];
};

여기에서 중복 제거를 더 해볼 수가 있다. 이 때는 매핑된 타입을 사용하면 조금 더 낫다.

type TopNavState = {
    [k in 'userId' | 'pageTitle' | 'recentFiles']: State[k]
};

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

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

여기서 Pick은 제너릭 타입이다. Pick을 사용하는 것은 함수를 호출하는 것과 마찬가지이다. 마치 함수에서 두 개의 매개변수 값을 받아서 결괏값을 반환하는 것처럼, Pick은 T와 K 두 가지 타입을 받아서 결과 타입을 반환한다.

값의 형태에 해당하는 타입을 정의하고 싶을 때도 있습니다.

const INIT_OPTION = {
    width: 640,
    height: 480,
    color: '#00FF00',
    label: 'VGA',
}

interface Options = {
    width: number;
    height: number;
    color: string;
    label: string;
}

이런 경우 typeof를 사용하면 된다.

type Options = typeof INIT_OPTIONS;

이 코드는 실제로 자바스크립트의 런타임 연산자 typeof를 사용한 것처럼 보이지만, 실제로는 타입스크립트 단계에서 연산되며 훨씬 더 정확하게 타입을 표현한다.

함수나 메서드의 반환 값에 명명된 타입을 만들고 싶을 수도 있다.

function getUserInfo(userId: string) {
    // ...
    return {
        userId,
        name,
        age,
        height,
        weight,
        favoriteColor,
    };
}
// 추론된 반환 타입은 { userId: string, name: string, age: number, ... }

이 때는 조건부 타입이 필요하다. 그러나 앞에서 살펴본 것처럼, 표준 라이브러리에는 이러한 일반적 패턴의 제너릭 타입이 정의되어 있다. 이런 경우 ReturnType 제너릭이 정확히 들어맞는다.

type UserInfo = ReturnType<typeof getUserInfo>;

제너릭 타입은 타입을 위한 함수와 같다. 그리고 함수는 코드에 대한 DRY 원칙을 지킬 때 유용하게 사용된다. 타입에 대한 DRY 원칙의 핵심은 제너릭이다. 함수에서 매개변수로 매핑할 수 있는 값을 제한하기 위해 타입 시스템을 사용하는 것처럼 제너릭 타입에서 매개변수를 제한할 수 있는 방법이 필요하다. 제너릭 타입에서 매개변수를 제한할 수 있는 방법은 extends를 사용하는 것이다. extends를 이용하면 제너릭 매개변수가 특정 타입을 확장한다고 선언할 수 있다.

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

자바스크립트 객체는 문자열 키를 타입의 값에 관계없이 매핑한다. 타입스크립트에서는 타입에 '인덱스 시그니처'를 명시하여 유연하게 매핑을 표현할 수 있다.

type Rocket = {[property: string]: string};
const rocket: Rocket = {
    name: 'Falcon 9',
    variant: 'v1.0',
    thrust: '4,940 KN',
}; // 정상

[property: string]: string 이 인덱스 시그니처이며, 다음 세 가지 의미를 담고 있다.

  • 키의 이름
  • 키의 타입
  • 값의 타입

이렇게 타입 체크가 수행이 되면 네 가지 단점이 드러난다.

  • 잘못된 키를 포함해 모든 키를 허용한다.
  • 특정 키가 필요하지 않다. {} 도 유효한 Rocket 타입이다.
  • 키마다 다른 타입을 가질 수 없다.
  • 키는 무엇이든 가능하기에, 자동완성 기능이 동작하지 않는다.

위와 같은 이유로 인덱스 시그니처는 부정확하므로 더 나은 방법을 찾아야 한다. 이 경우는 인터페이스가 더 적절한 대안이다. 인터페이스를 사용하면 타입스크립트에서 제공하는 언어 서비스를 모두 이용할 수 있다.

인덱스 시그니처는 동적 데이터를 표현할 때 사용한다. 예를 들면 CSV 파일처럼 헤더 행에 열 이름이 있고, 데이터 행을 열 이름과 값으로 매핑하는 객체로 나타내고 싶은 경우이다.

function parseCSV(input: string): {[columnName: string]: string}[] {
    const lines = input.split('\n');
    const [header, ...rows] = lines;
    const headerColumns = header.split(',');
    return rows.map(rowStr => {
        const row: {[columnName: string]: string} = {};
        rowStr.split(',').forEach((cell, i) => {
            row[headerColumns[i]] = cell;
        });
        return row;
    });
}

일반적인 상황에서 열 이름이 무엇인지 미리 알 방법은 없다. 이럴 때 인덱스 시그니처를 사용한다.

어떤 타입에 가능한 필드가 제한되어 있는 경우라면 인덱스 시그니처로 모델링하지 말아야 한다. 예를 들어 데이터에 A, B, C, D 같은 키가 있지만, 얼마나 많이 있는지 모른다면 선택적 필드 또는 유니온 타입으로 모델링하면 된다.

interface Row1 = {[column: string]: string}; // 너무 광범위
interface Row2 = { a: number; b?: number; c?: number; d?: number }; // 최선

string 타입이 너무 광범위해서 인덱스 시그니처를 사용하는데 문제가 있다면, 두 가지 다른 대안을 생각해 볼 수 있다.

첫 번째, Record를 사용하는 방법이다. Record는 키 타입에 유연성을 제공하는 제네릭 타입이다. 특히 string의 부분집합을 사용할 수 있다.

두 번째, 매핑된 타입을 사용하는 방법이다. 매핑된 타입은 키마다 별도의 타입을 사용하게 해 준다.

// 1.
type Vec3D = Record<'x'|'y'|'z', number>;

// 2. 
type Vec3D = {[k in 'x'|'y'|'z']: number};

type ABC = {[k in 'a'|'b'|'c']: k extends 'b' ? string : number};

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

자바스크립트의 객체는 키/값 쌍의 모음이다. 키는 보통 문자열이고, 값은 어떤 것이든 될 수 있다. 하지만 배열의 경우 객체인데 숫자 인덱스를 쓰고 이 경우에는 문자열 키를 사용해도 역시 배열의 요소에 접근할 수 있어서 혼란스러울 수 있다.

타입스크립트는 이러한 혼란을 바로잡기 위해 숫자키를 허용하고, 문자열 키와 다른 것으로 인식한다. Array에 대한 타입 선언은 lib.es5.d.ts에서 확인할 수 있다.

interface Array<T> {
    // ...
    [n: number]: T;
}

한편, Object.keys 같은 구문은 여전히 문자열로 반환된다.

인덱스 시그니처가 number로 표현되어 있다면 입력한 값이 number여야 한다는 것을 의미하지만, 실제 런타임에 사용되는 키는 string 타입이다.

어떤 길이를 가지는 배열과 비슷한 형태의 튜플을 사용하고 싶다면 타입스크립트에 있는 ArrayLike 타입을 사용한다.

function checkedAccess<T>(xs: ArrayLike<T>, i: number): T {
    if(i < xs.length) {
        return xs[i];
    }
    throw new Error(`배열의 끝을 지나 ${i}를 접근하려고 했습니다.`)
}

결론적으로 인덱스 시그니처에 number를 사용하기 보다 Array나 튜플, 또는 ArrayLike 타입을 사용하는 것이 좋다.

아이템 17. 변경 관련된 오류 방지를 위해 readonly 사용하기

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

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

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

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

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

자바스크립트에서(타입스크립트도 마찬가지)는 명시적으로 언급하지 않는 한, 함수가 매개변수를 변경하지 않는다고 가정한다. 그러나 이러한 암묵적인 방법은 타입체크에 문제를 일으킬 수 있다. 명시적인 방법을 사용하는 것이 컴파일러와 사람 모두에게 좋다.

어떤 함수를 readonly로 만들면, 그 함수를 호출하는 다른 함수도 모두 readonly로 만들어야 한다. 그러면 인터페이스를 명확히 하고 타입 안정성을 높일 수 있기 때문에 꼭 단점이라고 볼 순 없다. 그러나 다른 라이브러리에 있는 함수를 호출하는 경우라면, 타입 선언을 바꿀 수는 없으므로 타입 단언문(as number[])을 사용해야 한다.

인덱스 시그니처에도 readonly를 쓸 수 있다. 읽기는 허용하되 쓰기를 방지하는 효과가 있다.

let obj: {readonly [k: string]: number} = {};
obj.hi = 45; // ~ ... 형식의 인덱스 시그니처는 읽기만 허용된다.
obj = [...obj, hi: 12 ]; // 정상

이 코드처럼 인덱스 시그니처에 readonly를 사용하면 객체의 속성이 변경되는 것을 방지할 수 있다.

const 와 readonly의 차이점

const

  1. 변수 참조를 위한 것
  2. 변수에 다른 값을 할당/대입할 수 없음.

readonly

  1. 속성을 위한 것
  2. 속성을 앨리어싱을 통해 변경될 수 있음

1을 설명하는 예제:

const foo = 123; // 변수 참조
var bar: {
    readonly bar: number; // 속성의 경우
}

2를 설명하는 예제:

let foo: {
    readonly bar: number;
} = {
        bar: 123
    };

function iMutateFoo(foo: { bar: number }) {
    foo.bar = 456;
}

iMutateFoo(foo); // foo 인자가 foo 파라미터에 의해 앨리어싱됨
console.log(foo.bar); // 456!

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

산점도(scatter plot)를 그리기 위한 UI 컴포넌트를 작성한다고 가정해보자. 여기에는 디스플레이와 동작을 제어하기 위한 몇 가지 다른 타입의 속성이 포함된다.

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

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

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

불필요한 작업을 피하기 위해, 필요할 때만 차트를 다시 그릴 수 있다. 데이터나 디스플레이 속성이 변경이 되면 다시 그려야 하지만, 이벤트 핸들러가 변경되면 다시 그릴 필요가 없다.

최적화의 두 가지 방법

  1. 실패에 닫힌 접근법
    • 새로운 속성이 추가되면 shouldUpdate 함수는 값이 변경될 때 마다 차트를 다시 그릴 것이다. 이 접근법은 정확하나 너무 자주 그려질 가능성이 있다.
function shouldUpdate(oldProps: ScatterProps, newProps: ScatterProps) { 
  let k: keyof ScatterProps; 
  for (k in oldProps) { 
    if (oldProps[k] !== newProps[k]) { 
      if (k !== 'onClick') return true; 
    } 
  } 
}
  1. 실패에 열린 접근법
    • 이 코드는 차트를 불필요하게 다시 그리는 단점을 해결했다. 하지만 실제로 차트를 다시 그려야 할 경우 누락되는 일이 생길 수 있다.
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 // (no check for onClick) 
  );
}

새로운 속성이 추가될 때 직접 shouldUpdate를 고치게 하는 방법이 더 낫다. 이 때 타입 체커도 동작하도록 코드를 개선했다. 핵심은 매핑된 타입과 객체를 사용하는 것이다.

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;
    }
}

[k in keyof ScatterProps]은 타입 체커에게 REQUIRES_UPDATE가 ScatterProps과 동일한 속성을 가져야 한다는 정보를 제공한다. 나중에 ScatterProps에 새로운 속성을 추가하는 경우 REQUIRE_UPDATE의 정의에 오류가 발생한다. 이런 방식은 오류를 정확하게 잡아낼 수 있다.

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