본문 바로가기

HTML ⁄ CSS ⁄ JS

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

 

이펙티브 타입스크립트 정주행 스터디 2주차 전반부
(분량이 많아 전반/후반으로 나누어 작성함)

 

06 에디터로 타입시스템 탐색하기

특정 시점에 타입스크립트가 값의 타입을 어떻게 이해하고 있는지 살펴보는 것은 타입 넓히기, 타입 좁히기의 개념을 잡기 위해 꼭 필요한 과정이다.

타입스크립트 언어 서비스는 에디터상에서 'Go To Definition'(F12) 기능을 제공한다. 예를 들어 fetch의 경우 node_modules/typescript/lib/lib.dom.d.ts 에 타입이 정의되어 있다.

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

이렇게 타입을 탐색하는 과정에서 우리는 타입스크립트를 통해 라이브러리가 어떻게 모델링되어있는지, 그리고 어떻게 오류를 찾아낼 수 있는지 이해할 수 있게 된다.

 

07 타입이 값들의 집합이라고 생각하기

타입스크립트가 타입체크를 하는 순간을 생각해보자. 이 시점은 런타임 이전으로 아직 변수에 '값'이 할당되지 않았다. 하지만 각 변수는 '타입'을 가지고 있다. 이런 관점에서 타입은 '할당 가능한 값들의 집합' 이라고 할 수 있다. 이 집합을 '범위'라고 부르기도 한다.

타입체커는 하나의 집합이 다른 집합의 부분 집합인지 검사하는 역할을 수행한다고 볼 수 있다.

구조적타이핑을 따르는 타입스크립트에서 동일한 값의 집합을 가지는 두 타입은 같은 타입이다. 의미적으로 다르다고 하더라도 같은 타입을 두 번 정의할 이유는 없다.

가장 작은 집합은 공집합이고, 타입스크립트에서는 never에 해당한다. never 타입을 가지는 변수에는 아무런 값도 할당할 수 없다. never는 에러 객체같은 도달하면 안되는 곳에 보통 할당한다.

그 다음으로 작은 집합은 단 한가지 값만 포함하는 '유닛(unit)' 타입이라고 불리는 리터럴이 있다. 이를 두 개 이상으로 묶어 합집합을 구성한 것을 '유니온(union)'이라고 부른다.

& 연산자로 표현하는 타입 연산은 인터섹션(intersection) 타입이라고 부른다.

객체 타입에서의 A & B는 A와 B의 속성을 모두 가지는 것을 의미한다.

예를 들어 아래의 Person 타입은 name이라는 속성을 가질 수 있는 값들의 집합이다. Lifespan 타입은 birth, death 속성을 가질 수 있는 값들의 집합이다. 두 인터페이스의 교집합은, name, birth, death 속성을 가질 수 있는 값들의 집합이다. 당연히 다른 추가적인 속성을 가지더라도 PersonSpan 에 해당한다. (특정 상황에서 잉여속성 체크가 되는 것을 배제한다면) 

keyof ( A & B ) === (keyof A) | (keyof B)
keyof ( A | B ) === (keyof A) & (keyof B)
interface Person {
  name: string
}

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

type PersonSpan = Person & Lifespan

 

조금 더 일반적으로 PersonSpan 타입을 선언하는 방법은 extends 키워드를 사용하는 방법이다. extends 키워드는 제너릭 타입에서 한정자로도 쓰인다. K는 string의 부분 집합 범위를 가지는 어떠한 타입으로, string 리터럴 타입, string 타입의 유니온, string 자체가 포함된다.

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

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

function sortBy<K extends keyof T, T>(vals: T[], key: K): T[] {
  // ...
}

 

타입스크립트는 숫자의 쌍을 모델링할 때 length를 체크한다. 예를 들어 [number, number] 타입은 { 0: number, 1: number, length: 2 } 로 모델링 되고, [number, number, number]은 { 0: number, 1: number, 2:number, length: 3 } 으로 모델링 된다. [number, number] 타입에 [number, number, number] 타입을 할당하려고 하면 에 3은 2에 맞지 않는다는 타입에러가 발생한다.

 

 

08 타입 공간과 값 공간의 심벌 구분하기

'타입'인 심벌과  '값'인 심벌은 헷갈리기 쉽다. 두 공간에 대한 개념을 잡고 해당 심벌이 타입 공간에 속하는지, 값 공간에 속하는지 구분하자.

'타입'인 심벌은 주로 type, interface 다음에 나오거나 타입선언(:) 또는 타입 단언문(as) 다음에 나온다.

'값'인 심벌은 주로 const, let 다음에 나오거나 할당 연산자(=) 다음에 나온다.

typeof는 '타입'에서 쓰일 때와 '값' 에서 쓰일 때 다른 기능을 한다. '타입' 에서는 'typeof p' 와 같이 해당 '값'의 타입스크립트 타입을 반환한다. '값'에서는 대상의 런타임 타입을 가리키는 문자열(총 8개 중 하나)을 반환한다.

class, enum은 상황에 따라 '타입'과 '값' 둘다 가능한 예약어이다. 참고로 'InstanceType 제너릭'을 사용하면 생성자 타입에서 인스턴스 타입으로 전환할 수 있다.

class Cylinder {
  radius=1;
  height=1;
}

functino calculateVolume(shape: unknown) {
  if (shape instanceof Cylinder) { // 클래스로 쓰임 
    shape        // Cylinder 타입
    shape.radius // number 타입
  }
}

type T = typeof Cylinder;  // 타입이 typeof Cylinder
const v = typeof Cylinder; // 값이 "function" 
// 타입이 "string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function"

type C = InstanceType<typeof Cylinder>; // 타입이 Cylinder

 

obj.field와 obj['field']는 값이 동일하더라도 타입은 다를 수 있다. 타입의 속성을 얻고싶은 경우 반드시 obj['field']으로 써야한다. 타입 선언(:) 뒤에 쓰인 대괄호 안에서는 유니온 타입 등 모든 타입 표현이 가능하다.

interface Person {
  first: string;
  last: string;
}

const first: Person['first'] = p.first

 

 

09 타입 단언보다는 타입 선언 사용하기

타입스크립트에서 타입을 부여하는 방법은 타입 '선언(declaration)'과 '단언'(assertion) 두 가지가 있다. 꼭 필요한 경우를 제외하고는 타입 '단언'보다는 타입 '선언'을 사용하는 것이 좋다.

interface Person {
  { name : string };
}

const alice: Person = { name: 'Alice' };
const bob = { name: 'Bob' } as Person;

 

우선 타입 '선언'을 사용하면, 해당 변수에 할당되는 값이 해당 인터페이스를 만족하는지 검사한다. 타입체커 기능을 십분 활용할 수 있다.

interface Person {
  name: string;
}

// 모두 타입이 Person[]
const people1 = ['alice', 'bob', 'jan'].map((name): Person => ({ name }));

const people2 = ['alice', 'bob', 'jan'].map<Person>((name) => ({ name }));

const people3: Person[] = ['alice', 'bob', 'jan'].map((name) => ({ name }));

 

반면, 타입 '단언'을 사용하면 타입스크립트가 추론한 타입이 있더라도 단언 처리한 타입으로 간주된다. 강제로 타입을 지정하니, 타입체커가 오류를 무시하게 되는 문제가 있다.

단, 타입스크립트 보다 작성자가 타입 정보를 더 잘 알고 있는 경우 예외적으로 타입 '단언'을 사용할 수 있다.

예를 들어, 타입스크립트는 DOM에 접근할 수 없어 어떤 HTML 엘리먼트인지 단언해주는 것은 타당하다. 또 null 이 아님을 확신할 수 있을 때는 문장 뒤에 null이 아님을 단언하는 non-null assertion 연산자(!) 를 붙여줄 수 있다. 그렇지만 null 이 아니라고 확신할 수 없을 경우에는 null 인 경우를 체크하는 조건문을 사용하는 것이 맞다.

documnet.querySelector('#myButton').addEventListener('click', e => {
  e.currentTarget // 타입은 EventTarget
  const button = e.currentTarget as HTMLButtonElement; // 타입은 HTMLButtonElement
  // HTMLButtonElement는 EventTarget의 서브타입이기 때문에 단언 가능

  const elNull = document.getElementById('foo'); // 타입은 HTMLElement | Null
  const el = document.getElementById('foo')!; // 타입은 HTMLElement

  // ...
})

 

10 객체 래퍼타입 피하기

자바스크립트에서는 String 과 같은 객체래퍼 타입 덕분에 원시타입에서 메서드를 사용할 수 있다. 예를 들어 'myString'.chartAt 과 같이 string에서 메서드를 호출하면 자바스크립트는 (1) 원시타입인 string을 객체로 래핑하고, (2) 메서드를 호출한 뒤, (3) 래핑한 객체를 버린다. 

타입스크립트에서는 다음과 같이 원시타입과 객체 래퍼타입을 별도로 모델링한다. 이 때, 래퍼타입 보다는 원시타입을 사용하는 것이 좋다. 래퍼타입에 원시타입을 할당하는 오해를 낳기 쉽고, 굳이 그렇게 할 필요도 없어 지양하는 것이 좋다.

string vs String
number vs Number
boolean vs Boolean
symbol vs Symbol
bigint vs BigInt

래퍼타입 String에 원시타입 string을 할당하는 것은 가능하다. 그러나 반대로 원시타입 string에 래퍼타입 String을 할당할 수는 없다는 점을 알아두자.

당연하게도 타입스크립트가 제공하는 타입을 포함해 대부분의 라이브러리에서는 타입 선언에 원시타입을 활용한다.

 

11 잉여 프로퍼티 체크의 한계 인지하기

잉여 프로퍼티 체크(excess property checks)는 '객체 리터럴'을 할당할 때 타입스크립트는 해당 타입의 속성이 있는지, 그리고 그 외의 속성은 없는지 확인하는 과정이다.

알 수 없는 속성을 허용하지 않는 성격 때문에 엄격한 객체 리터럴 체크(stricter object literal assignment checks)라고도 부른다.

이러한 잉여 프로퍼티 체크는 일반적으로 수행되는 할당 가능여부 체크와는 별도로 이루어지는 과정으로, 구조적 타이핑에서 발생할 수 있는 오류를 잡아준다.

그런데 타입스크립트의 잉여 프로퍼티 체크는 조건에 따라 동작하기도, 동작하지 않기도 하기 때문에 어떤 상황에서 동작하는지 알아두어야 한다. 

객체 리터럴로 바로 할당하는 경우, 잉여 프로퍼티 체크가 동작한다.

interface Options {
  title: string;
  darkMode?: boolean;
}

const opt1: Options = { title: 'Ski', darkmode: true };
// 잉여 프로퍼티 체크 동작 O
// 에러, 'Options' 형식에 'darkmode'가 없습니다.

const temp = { title: 'Ski', darkmode: true };
const opt2: Options = temp;
// 잉여 프로퍼티 체크 동작 X
// 정상

const opt3 = { title: 'Ski', darkmode: true } as Options; 
// 잉여 프로퍼티 체크 동작 X
// 정상

 

한편, 인터페이스의 모든 속성이 optional 인 경우에도 잉여 프로퍼티 체크가 동작한다. 이렇게 optional 프로퍼티만 가지는 타입을 특별히 '약한 타입(Weak Types)'이라고 부른다.

interface OptionsAllOptional {
  title?: string;
  darkMode?: boolean;
}

const temp = { darkmode: true };
const opt4: OptionsAllOptional = temp;
// 잉여 프로퍼티 체크 동작 O
// 에러, { darkmode: true } 타입에 OptionsAllOptional 타입과 공통적인 프로퍼티가 없습니다.

 

잉여 프로퍼티 체크를 원하지 않는다면, 다음과 같이 인덱스 시그니처로 추가적인 프로퍼티를 예상하도록 할 수 있다. 그렇지만 이렇게 되면 너무 광범위한 타입을 받아들 일 수 있는 구조가 되기 때문에, 인덱스 시그니처는 CSV 파일에서 로드하는 경우와 같이 런타임 때까지 객체의 속성을 알 수 없는 상황에서만 사용하는 것이 좋다.

interface Options {
  darkMode?: boolean;
  [otherOptions: string]: unknown;
}

const opt: Options = { title: 'Ski', darkmode: true };
// 잉여 프로퍼티 체크 동작 X
// 정상

 

 

 

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

타입스크립트에서는 '함수선언문'과 '함수표현식' 중에서 함수표현식을 사용하는 것이 더 좋다. 함수표현식에 붙여줄 '함수타입'을 만들고 재사용하는 방법이 불필요한 코드 중복을 제거하는데 도움이 된다.

다음과 같이 매개변수의 타입과 리턴값의 타입을 하나의 '함수 타입'에 담아 선언해두면 재사용할 수 있다. 이러한 방법은 코드를 더욱 간결하고 안전하게 작성할 수 있도록 해준다.

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;

라이브러리에서는 공통 함수 타입을 제공하기도 한다. 리액트의 MouseEventHandler가 그 예시이다. MouseEvent 타입을 매개변수에 명시하는 대신에 핸들러 함수 자체에 타입을 지정해줄 수 있다.

다른 함수의 시그니처를 참조하려면 typeof fn 구문을 사용하면 된다. 다음 예제의 checkFetch 함수는 fetch 와 동일한 매개변수 타입, 리턴 타입이 되도록 보장한다. 

/*
// lib.dom.d.ts
declare function fetch(
  input: RequestInfo, init?: RequestInit
): Promise<Response>;
*/
 
// index.ts
const checkFetch: typeof fetch = async (input, init) => {
  const response = await fetch(input, init);
  if (!response.ok) {
    throw new Error('Request failed: ' + response.status);
  }
  return response;
}