Front-end Developer

0%

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

this는 let,const와 달리 dynamic scope이기 때문에 정의된 방식이 아닌 호출된 방식에 따라 값이 달라진다. 아래 코드는 현재의 객체 인스턴스를 참조하는 클래스에서 많이 쓰이는 this이다.

1
2
3
4
5
6
7
8
9
10
11
class C {
vals = [1, 2, 3]
logSquares() {
for (const val of this.vals) {
console.log(val * val)
}
}
}
const c = new C()
const method = c.logSquares
method()

이 코드는 런타임에 uncaught TypeError: undefined의 ‘vals’속성을 읽을 수 없습니다.는 오류를 발생시킨다. 그 이유는 c.logSquaresC.prototype.logSquares를 호출하고, this 값을 c로 바인딩하는 두 가지 작업을 수행하기 때문이다. 이 작업에 따르면 this의 값은 undefined로 설정된다. 이런 문제를 해결하는 첫 번째 방법은 call을 사용해서 명시적으로 this를 바인딩하는 것이다.

1
2
3
const c = new C()
const method = c.logSquares
method.call(c) // 제곱 출력

이처럼 명시적인 this 바인딩을 통해 this의 타입에 대해 구체화할 수 있다. this 바인딩은 어떤 것에도 가능하며, 콜백 함수에도 쓰일 수 있다.

1
2
3
4
5
6
7
8
9
declare function makeButton(props: { text: string; onClick: () => void }): void
class ResetButton {
render() {
return makeButton({ text: 'Reset', onClick: this.onClick })
}
onClick() {
alert(`Reset ${this}`)
}
}

그런데 여기서도 this 바인딩 문제로 인해 Reset이 정의되지 않았다는 에러가 발생한다. 이때 해결하는 방법은 아래와 같이 생성자에서 메서드에 this를 바인딩시키는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
declare function makeButton(props: { text: string; onClick: () => void }): void
class ResetButton {
constructor() {
this.onClick = this.onClick.bind(this)
}
render() {
return makeButton({ text: 'Reset', onClick: this.onClick })
}
onClick() {
alert(`Reset ${this}`)
}
}

onClick()은 ResetButton.proptotype의 속성을 정의한다. 따라서 ResetButton의 모든 인스턴스에 공유된다. 그런데 위와 같이 생성자 함수에 바인딩하게 되면 onClick 속성에 this가 바인딩되어 해당 인스턴스에 생성된다. onClick 인스턴스 속성은 프로토타입 속성보다 앞에 놓이므로 render() 메서드의 this.onClick은 바인딩된 함수를 참조하게 된다. 하지만 onClick을 화살표 함수로 바꾸는 방법으로 좀 더 간단하게 해결할 수도 있다. 그 이유는 화살표 함수 내부에서 this를 참조하면 상위 컨텍스트의 this를 그대로 참조하기 때문이다. 따라서 ResetButton이 생성될 때 마다 제대로 바인딩된 this를 가지는 새 함수를 생성한다.


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

1
2
3
4
5
6
7
8
function double(x: number | string): number | string
function double(x: any) {
return x + x
}

function double(x) {
return x + x
}

함수 오버로딩(동일한 이름에 매개변수만 다른 여러 버전의 함수를 허용하는 것.단, 타입 수준에서 동작)의 개념을 통해 위와 같이 함수의 타입 정보를 추가한다. 그런데 이렇게 작성하면 선언문에서 number타입을 매개변수로 넣고, string타입을 반환하는 경우도 포함되어 있어서 모호한 지점이 생긴다. 이런 상황에서 첫 번째 해결법은 제너릭을 사용하는 것이다.

1
2
3
4
5
6
7
function double<T extends number | string>(x: T): T
function double(x: any) {
return x + x
}

const num = double(12) // Type is 12
const str = double('x') // Type is "x"

제너릭 타입을 쓰면서 앞서 말했던 number로 선언되어 string 타입을 반환하는 경우에 대해서 해결은 되었으나 지나치게 구체적인 타입이 되었다. string을 넣으면 string만 반환해야 하게 되었다. 좀 더 다른 방법으로 여러 가지 타입 선언으로 분리해 볼 수 있다. 함수의 구현체는 하나여도 타입 선언은 여러 개 만들 수 있기 때문에 함수 타입을 보다 명확하게 할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
function double(x: number): number
function double(x: string): string
function double(x: any) {
return x + x
}

const num = double(12) // Type is number
const str = double('x') // Type is string
function f(x: number | string) {
return double(x)
// ~ Argument of type 'string | number' is not assignable
// to parameter of type 'string'
}

단 유니온 타입에 대해서는 문제가 발생한다. 타입스크립트는 오버로딩 타입 중에서 일치하는 타입을 찾을 때까지 순차적으로 검색한다. 마지막 선언인 string까지 검색했을 때 string | number 타입은 string에 할당할 수 없기 때문에 에러가 발생하는 것이다. 이때는 오버로딩 타입으로 string|number를 추가하여 문제를 해결할 수 있고, 더 좋은 방법은 아래와 같이 조건부 타입을 사용하는 것이다.

1
2
3
4
5
6
function double<T extends number | string>(
x: T
): T extends string ? string : number
function double(x: any) {
return x + x
}

조건부 타입을 사용하면 아래와 같이 반환 타입이 정교해진다.

  • T가 string의 부분 집합이면 반환 타입이 string이다.
  • 그 외의 경우는 반환 타입이 number이다.

이처럼 조건부 타입은 개별 타입의 유니온으로 일반화하기 때문에 타입이 더욱 정확해진다. 조건부 타입은 타입 체커가 단일 표현식으로 받아들이기 때문에 타입 오버로딩에서 유니온 문제가 발생했을 때 문제를 해결할 수 있다.


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

1
2
3
4
5
6
7
8
9
function parseCSV(contents: string | Buffer): { [column: string]: string }[] {
if (typeof contents === 'object') {
// It's a buffer
return parseCSV(contents.toString('utf8'))
}
// COMPRESS
return []
// END
}

다음 코드는 NodeJs 사용자를 위해 매개변수에 Buffer 타입을 허용한 예시이다. 그리고 Buffer의 타입 정의는 npm install —save-dev @types/node로 설치하여 얻을 수 있다. 단 타입 선언이 @types/node에 의존하기 때문에 devDependencies로 포함해야 하는데 이를 포함하면 @types와 무관한 자바스크립트 개발자 또는 NodeJS와 무관한 타입스크립트 웹 개발자에게 혼란을 줄 수 있다. 그 이유는 두 그룹 사용자가 사용하지 않는 모듈이 포함되어 있기 때문이다. Buffer는 NodeJS 개발자에게만 필요하다. 따라서 Buffer를 사용하는 대신 아래와 같이 필요한 메서드와 속성만 별도로 작성하는 방식으로 개선할 수 있다.

1
2
3
4
5
6
7
8
9
10
interface CsvBuffer {
toString(encoding: string): string
}
function parseCSV(
contents: string | CsvBuffer
): { [column: string]: string }[] {
// COMPRESS
return []
// END
}

만약 작성 중인 라이브러리가 의존하는 라이브러리의 구현과 무관하게 타입에만 의존한다면, 필요한 선언부만 추출해서 작성 중인 라이브러리에 넣는 미러링을 고려해 볼 수 있다.


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

타입 선언도 테스트를 거쳐야 하고, dtslint 또는 타입 시스템 외부의 타입을 검사하는 도구를 사용할 수 있다. 타입 선언이 예상한 타입으로 결과를 내는지 체크할 수 있는 한 가지 방법은 함수를 호출하는 테스트 파일의 작성이다. 그러나 단순히 함수를 실행만 하는 테스트 코드를 작성하는 것보다는 반환 타입을 체크하는 것이 중요하다. 반환값을 특정 타입의 변수에 할당하여 간단히 반환 타입을 체크할 수 있는 방법은 다음과 같다.

1
const lengths: number[] = map(['john', 'paul'], name => name.length)

이 코드는 불필요한 타입 선언에 해당하지만 테스트 관점에서는 매우 중요하다. 그런데 테스팅을 위해서 할당하는 방법에는 두 가지 문제가 있다.

  • 불필요한 변수를 만들어야 하고, 일부 린팅 규칙을 비활성화해야한다.

    • 변수를 도입하는 대신 헬퍼 함수를 정의하는 것으로 해결한다.
    1
    2
    3
    function assertType<T>(x: T) {}

    assertType<number[]>(map(['john', 'paul'], name => name.length))
  • 두 타입이 동일한지에 대한 체크 대신 할당 가능성을 체크한다.

1
2
const n = 12
assertType<number>(n) //정상

n 심벌은 타입이 숫자 리터럴 12인데, 12는 number의 서브타입이라서 할당 가능성 체크를 통과한다. 그러나 객체 타입 체크해서 문제가 발생한다.

1
2
3
4
5
6
7
const beatles = ['john', 'paul', 'george', 'ringo']
assertType<{ name: string }[]>(
map(beatles, name => ({
name,
inYellowSubmarine: name === 'ringo'
}))
) // OK

map은 {name: string, inYellowSubmarine: boolean}객체의 배열을 반환하는데, 반환된 배열은 {name: string}[]에 할당 가능하지만 inYellowSubmarine 속성에 대해 체크되지 않는다.

assertType 사용 방법

아래 예제처럼 parameters와 ReturnType 제너릭 타입을 이용해서 함수의 매개변수 타입과 반환 타입만 분리하여 테스트한다.

1
2
3
4
5
6
7
const double = (x: number) => 2 * x
let p: Parameters<typeof double> = null!
assertType<[number, number]>(p)
// ~ Argument of type '[number]' is not
// assignable to parameter of type [number, number]
let r: ReturnType<typeof double> = null!
assertType<number>(r) // OK

아이템 53 타입스크립트 기능보다는 ECMAScript 기능을 사용하기

타입스크립트 초기 버전에는 자바스크립트가 가진 결함들도 수용해야 했기 때문에 독립적으로 개발한 클래스, 열거형, 모듈 시스템을 포함시켰다. 시간이 흐르면서 부족했던 부분들을 내장 기능으로 추가하게 되었는데 자바스크립트에 새로 추가된 기능은 타입스크립트 초기 버전에서 독립적으로 개발했던 기능과 호환성 문제를 발생시킨다. 그래서 타입스크립트는 자바스크립트의 신규 기능을 그대로 채택하고 타입스크립트 초기 버전과 호환성을 포기하는 방법을 택했다. 그런데 이 기능과 타입스크립트 팀은 타입만 발전시킨다는 원칙이 세워지기 이전에 이미 사용되던 몇 가지 기능들이 있고, 이런 것들이 타입 공간(타입스크립트), 값 공간(자바스크립트)의 경계를 혼란스럽게 하기 때문에 사용을 지양해야 한다.


References
[이펙티브 타입스크립트] 댄 밴더캄 지음

아이템 42 모르는 타입의 값에는 any 대신 unknown을 사용하기

any의 특성

  • 어떠한 타입이든 any 타입에 할당 가능하다.
  • any 타입은 never를 제외한 어떠한 타입으로도 할당 가능하다.

이러한 any의 특성은 집합 기반의 타입시스템과 상충된다. 따라서 any를 쓰면 타입 체커가 무용지물이 된다. 마치 any와 같은 역할을 하면서 타입시스템에 부합하는 타입이 바로 unknown이다.

unknown의 특성

  • 어떠한 타입도 unknown에 할당 가능하다.
  • 오직 unknown과 any에만 할당 가능하다.

never의 특성

  • 어떤 타입도 never에 할당 할 수 없다.
  • 어떠한 타입으로도 할당 가능하다.

1. unknown의 함수의 반환값과 관련된 형태

함수의 반환타입으로 any를 쓰는 것은 좋지 않기 때문에 그보다는 함수를 호출한 곳에서 반환값을 원하는 타입으로 할당하는 것이 바람직하다. 그러나 함수의 반환값에 타입 선언을 강제할 수 없기 때문에 호출한 곳에서 타입 선언을 생략하면 아래 예제를 예시로 들었을 때 1️⃣, 2️⃣처럼 book 변수가 암시적 any타입이 되고, 사용되는 곳마다 타입 오류가 발생한다. 이런 경우에 any 대신 unknown을 쓰면 훨씬 안전하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function parseYAML(yaml: string): any {
// ...
}
interface Book {
name: string
author: string
}
function safeParseYAML(yaml: string): unknown {
return parseYAML(yaml)
}
const book = safeParseYAML(`
name: The Tenant of Wildfell Hall
author: Anne Brontë
`)
alert(book.title)
// 1️⃣ - No error, alerts "undefined" at runtime
// ~~~~ Object is of type 'unknown'
book('read')
// No error, throws "TypeError: book is not a
// 2️⃣ - function" at runtime
// ~~~~~~~~~~ Object is of type 'unknown'

하지만 unknown인 타입의 값을 사용하려고 하거나 함수 호출 또는 연산을 진행할 때 오류가 발생하기 때문에 적절한 타입으로 변환하도록 강제할 수 있다.

2. 변수선언과 관련된 unknown

어떠한 값이 있지만 그 타입을 모르는 경우에 사용한다.


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

몽키 패치는 프로그램이 런타임 되는 동안 사용되는 모듈이나 클래스를 변경하는 것이다. 자바스크립트는 객체와 클래스에 임의의 속성을 추가할 수 있을 만큼 유연하기 때문에 이 성질을 이용해서 전역 변수를 만들 수도 있다. 예를 들어 window나 document에 값을 할당해서 전역 변수를 만드는 것이다. 그런데 window 또는 DOM 노드에 데이터를 추가하면 그 데이터는 기본적으로 전역 변수기 때문에 프로그랢 내에서 의존성을 만들고, 사이드 이펙트를 만들 수 있다. 그리고 타입스크립트의 타입 체커는 아래와 같이 임의로 추가한 속성에 대해서 알지 못한다.

1
2
document.monkey = 'Tamarin'
// ~~~~~~ Property 'monkey' does not exist on type 'Document'

References
[이펙티브 타입스크립트] 댄 밴더캄 지음

아이템 35 데이터가 아닌, API와 명세를 보고 타입 만들기

파일 형식, API, 명세 등 우리가 다루는 타입 중 최소한 몇 개는 프로젝트 외부에서 비롯된 것이다. 이런 경우 자동으로 타입 생성이 가능하다. 단 중요한 포인트는 예시 데이터가 아니라 명세를 참고해서 타입을 생성한다는 것이다. 명세를 참고하지 않고 예시 데이터를 참고해서 타입을 작성하게 되면 눈앞에 있는 데이터만 고려하게 되므로 오류 발생을 야기하기 쉽다. 그래서 명세가 존재한다면 아래 예시처럼 이미 존재하는 타입스크립트 타입 선언을 명시해서 사용할 수 있다. 다만 이미 존재하는 타입 선언을 import해서 쓰더라도 GeometryCollection에 coordinates의 속성이 없다면 에러가 발생할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// requires node modules: @types/geojson

interface BoundingBox {
lat: [number, number];
lng: [number, number];
}
import { Feature, Geometry } from 'geojson';
declare let f: Feature;
function helper(coordinates: any[]) {}
const geometryHelper = (g: Geometry) => {
if (geometry.type === 'GeometryCollection') {
geometry.geometries.forEach(geometryHelper);
} else {
helper(geometry.coordinates); // OK
}
};

const { geometry } = f;
if (geometry) {
geometryHelper(geometry);
}

이럴 때는 아래와 같이 명시적으로 해당하는 타입을 차단하는 방법을 사용할 수 있다.

1
2
3
4
5
6
7
8
const { geometry } = f;
if (geometry) {
if (geometry.type === 'GeometryCollection') {
throw new Error('GeometryCollection are not supported.');
} else {
helper(geometry.coordinates); // OK
}
}

하지만 명시적 타입 차단보다는 모든 타입을 지원하되 조건을 분기하여 helper함수를 지원하는 방식으로 작성해야 한다.


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

엄선된 타입, 속성, 변수의 이름은 의도를 명확히 하고, 코드와 타입의 추상화 수준을 높여 준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 속성에 대한 정보가 모호하고,
// 선언된 용어로 그 의미를 파악하기 어렵다.
interface Animal {
name: string;
endangered: boolean;
habitat: string;
}

const leopard: Animal = {
name: 'Snow Leopard',
endangered: false,
habitat: 'tundra',
};

//위의 코드를 전문용어를 베이스로 하여
//보다 분명한 의미를 나타내도록 변경하였다.
interface Animal {
commonName: string;
genus: string;
species: string;
status: ConservationStatus;
climates: KoppenClimate[];
}
type ConservationStatus = 'EX' | 'EW' | 'CR' | 'EN' | 'VU' | 'NT' | 'LC';
type KoppenClimate =
| 'Af'
| 'Am'
| 'As'
| 'Aw'
| 'BSh'
| 'BSk'
| 'BWh'
| 'BWk'
| 'Cfa';
const snowLeopard: Animal = {
commonName: 'Snow Leopard',
genus: 'Panthera',
species: 'Uncia',
status: 'VU', // vulnerable
climates: ['ET', 'EF', 'Dfd'], // alpine or subalpine
};

이처럼 코드로 표현하고자 하는 모든 분야에는 주제를 설명하기 위한 전문 용어들이 존재한다. 전문 용어가 있다면 자체적으로 용어를 만들어 내기 보다 전문 용어를 사용하는 것이 좋지만 잘못 사용하여 혼란을 주지 않도록 아래의 세가지를 주의하여 정확하게 사용해야 한다.

  • 동일한 의미를 나타낼 때는 같은 용어를 사용한다.
  • data, info, thing, item, object, entity와 같이 모호하고 의미없는 이름은 붙이지 않는다.
  • 이름을 지을 때 포함된 내용이나 계산 방식이 아닌 데이터 자체가 무엇인지를 고려한다.

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

타입스크립트가 가진 구조적 타이핑 특성 때문에 코드가 이상한 결과를 낼 수 있다. 1️⃣은 구조적 타이핑 관점에서는 문제가 없는데, 수학적으로는 2차원 벡터를 사용해야 이치에 맞다. 만약 calculateNorm 함수가 3차원 벡터를 허용하지 않게 하려면 2️⃣의 _brand처럼 공식 명칭을 사용한다. 공식 명칭이란 상표(_brand)를 붙이는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//1️⃣
interface Vector2D {
x: number;
y: number;
}
function calculateNorm(p: Vector2D) {
return Math.sqrt(p.x * p.x + p.y * p.y);
}

calculateNorm({ x: 3, y: 4 }); // OK, result is 5
const vec3D = { x: 3, y: 4, z: 1 };
calculateNorm(vec3D); // OK! result is also 5

//2️⃣
interface Vector2D {
_brand: '2d';
x: number;
y: number;
}
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); // Same as before
}

calculateNorm(vec2D(3, 4)); // OK, returns 5
const vec3D = { x: 3, y: 4, z: 1 };
calculateNorm(vec3D);
// ~~~~~ Property '_brand' is missing in type...

위 예시처럼 공식명칭(_brand)을 붙여두면 Vector2D함수가 Vector2D만 받는 것을 보장하게 된다. 이 기법은 타입 시스템 내에서 표현할 수 없는 수많은 속성들을 모델링하는 데 사용하기도 한다. 아래와 같이 number 타입에 상표도 단위를 붙여서 사용하는 것처럼 숫자의 단위를 문서화하는데 사용할 수도 있다.

1
2
3
4
5
6
7
8
9
10
type Meters = number & { _brand: 'meters' };
type Seconds = number & { _brand: 'seconds' };

const meters = (m: number) => m as Meters;
const seconds = (s: number) => s as Seconds;

const oneKm = meters(1000); // Type is Meters
const oneMin = seconds(60); // Type is Seconds
const tenKm = oneKm * 10; // Type is number
const v = oneKm / oneMin; // Type is number

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

전통적으로 프로그래밍 언어들의 타입 시스템은 완전히 정적이거나 완전히 동적으로 구분되어 있다. 그러나 타입스크립트의 타입 시스템은 선택적이고 점진적이기 때문에 정적이면서도 동적인 특성을 동시에 가진다. 그래서 프로그램의 일부에만 타입스크립트를 적용할 수 있어 점진적 마이그레이션이 가능하다. 이 마이그레이션 단계에서 any 타입이 아주 중요한 역할을 하는데, any를 어떻게 현명하게 사용할 수 있을지 잘 고민해보아야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
interface Foo {
foo: string;
}
interface Bar {
bar: string;
}
declare function expressionReturningFoo(): Foo;
function processBar(b: Bar) {
/* ... */
}

//1️⃣
function f1() {
const x: any = expressionReturningFoo(); // Don't do this
processBar(x);
}

//2️⃣
function f2() {
const x = expressionReturningFoo();
processBar(x as any); // Prefer this
}

위의 예제에서 x가 Foo와 Bar에 동시에 할당가능하다면 위와 같이 any를 사용한 두 가지 방법으로 해결할 수 있는데 1️⃣보다 2️⃣가 권장된다. 그 이유는 any 타입이 processBar 함수의 매개변수에만 사용된 표현식이라서 다른 코드에 영향을 미치지 않기 때문이다. 그래서 1️⃣의 x는 끝까지 any타입이고, 2️⃣에서 x는 Foo 타입을 유지한다. 1️⃣의 x가 끝까지 any 타입이기 때문에 아래와 같이 x를 반환하는 코드에서는 문제가 된다.

1
2
3
4
5
6
7
8
9
10
function f1() {
const x: any = expressionReturningFoo();
processBar(x);
return x;
}

function g() {
const foo = f1(); // Type is any
foo.fooMethod(); // This call is unchecked!
}

객체에서 any를 사용할 때도 조심해야 하는데, 아래와 같이 오류를 발생하는 코드가 있다고 생각해본다.

1
2
3
4
5
6
7
8
const config: Config = {
a: 1,
b: 2,
c: {
key: value,
// ~~~ Property ... missing in type 'Bar' but required in type 'Foo'
},
};

여기서 발생하는 오류를 해결하기 위해 아래의 두 가지 방법으로 any를 쓸 수 있다. 하지만 1️⃣처럼 사용하면 다른 속성들인 a,b의 타입도 체크되지 않는다. 따라서 2️⃣와 같이 사용하도록 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//1️⃣
const config: Config = {
a: 1,
b: 2,
c: {
key: value,
},
} as any; // Don't do this!

//2️⃣
const config: Config = {
a: 1,
b: 2, // These properties are still checked
c: {
key: value as any,
},
};

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

  • any는 타입의 범위가 매우 넓기 때문에 사용할 때는 정말로 모든 값이 허용되어야하만 하는지 면밀히 검토한다.
  • any보다 더 정확하게 모델링할 수 있도록 any[] 또는 {[id:string]:any}또는 () => any를 사용한다.
  • {[id:string]:any}는 함수의 매개변수가 객체인데 값을 알 수 없는 경우에 쓰인다.

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

프로젝트 전반에 위험한 타입 단언문이 드러나 있는 것보다 제대로 타입이 정의된 함수 안으로 타입 단언문을 감추는 것이 좋다.

1
2
3
4
5
6
7
8
9
10
11
12
declare function shallowEqual(a: any, b: any): boolean;
function cacheLast<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;
}

위 예제함수에서 함수 내부에는 any가 많이 보이지만 타입 정의에는 any가 없기 때문에 cacheLast를 호출하는 쪽에는 any가 사용됐는지 알 수 없다. 아래 예제에서 shallowobjectEqual은 객체를 매개변수로 하는 함수로 타입 정의는 간단하지만 구현이 복잡하다. 여기서 b as any로 선언할 수 있었던 것은 체크를 통해 k in b를 체크하였기 때문이다. 이 타입 단언문이 없다면 k in b가 b 객체에 k 속성이 있다는 것이 확인되었음에도 불구하고, Element implicitly has an ‘any’ type because type ‘{}’ has no index signature 에러가 발생한다. 이는 실제 에러가 아니기 때문에 any로 타입을 단언한 것이다.

1
2
3
4
5
6
7
8
9
declare function shallowEqual(a: any, b: any): boolean;
function shallowObjectEqual<T extends object>(a: T, b: T): boolean {
for (const [k, aVal] of Object.entries(a)) {
if (!(k in b) || aVal !== (b as any)[k]) {
return false;
}
}
return Object.keys(a).length === Object.keys(b).length;
}

이처럼 타입 선언문은 일반적으로 타입을 위험하게 만들지만 상황에 따라 필요하기도 하고 현실적이 해결책이 되기도 한다. 불가피하게 사용해야 한다면, 정확한 정의를 가지는 함수 안으로 숨기도록 한다.


아이템 41 any의 진화를 이해하기

타입스크립트에서 일반적으로 변수의 타입을 선언할 때 타입이 결정된다. null 체크 등을 통해 타입을 정제할 수는 있지만 새로운 값이 추가되도록 확장할 수 없다. 단 any 타입은 예외이다. 타입은 진화하는데 이는 타입 좁히기의 개념과 전혀 다르다.

배열에 다양한 타입의 요소를 넣으면 배열의 타입이 확장되며 진화한다.

1
2
3
4
5
const result = []; // Type is any[]
result.push('a');
result; // Type is string[]
result.push(1);
result; // Type is (string | number)[]

조건문에서 분기에 따라 타입이 변한다.

1
2
3
4
5
6
7
8
9
let val; // Type is any
if (Math.random() < 0.5) {
val = /hello/;
val; // Type is RegExp
} else {
val = 12;
val; // Type is number
}
val; // Type is number | RegExp

변수의 초기값이 null일 때 진화한다.

1
2
3
4
5
6
7
8
9
10
function somethingDangerous() {}
let val = null; // Type is any
try {
somethingDangerous();
val = 12;
val; // Type is number
} catch (e) {
console.warn('alas!');
}
val; // Type is number | null

noImplicitAny가 설정된 상태에서 변수의 타입이 암시적 any이다.

1
2
3
4
5
6
7
8
9
let val: any; // Type is any
if (Math.random() < 0.5) {
val = /hello/;
val; // Type is any
} else {
val = 12;
val; // Type is any
}
val; // Type is any

any타입의 진화는 암시적 any 타입에 어떤 값을 할당할 때만 발생한다. 그래서 어떤 변수가 암시적 any 상태일 때 어떠한 변수에도 할당하지 않고 값을 읽으려 하면 오류가 발생한다.


References
[이펙티브 타입스크립트] 댄 밴더캄 지음

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

효과적으로 타입을 설계하려면 유효한 상태만 표현할 수 있는 타입을 만드는 것이 중요하다.

유효하지 않은 상태

  • A,B가 있다고 했을 때, 상태 값의 두 가지 속성이 동시에 정보가 부족하다. (A가 성공인지 실패인지 알 수 없다.)
  • A,B의 두 가지 속성이 충돌한다. (A이면서 B인 상태가 있다.)

이런 무효한 상태가 존재하면 두 가지 모두를 제대로 구현할 수 없게 된다. 유효한 상태를 구현하려면 아래와 같이 명시적으로 모델링하는 태그된 유니온(또는 구별된 유니온)을 사용해서 나타낸다. 이렇게 작성하면 코드가 길어지고 작성하기 어렵지만 무효한 상태를 허용하지 않도록 개선할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 사용할 때는 너그럽게, 생성할 때는 엄격하게

함수의 시그니처는 당신의 작업은 엄격하게, 다른 사람의 작업은 너그럽게 받아들인다는 일반적인 원칙을 따라야 한다. 즉 함수의 매개변수는 타입의 범위가 넓어도 되지만 결과를 반환할 때는 타입의 범위가 더 구체적이어야 한다.

  • viewportForBounds의 타입의 선언이 만들어지고 사용될 때처럼 너무 자유로우면 오류가 발생하기 쉽다.
  • 너무 자유롭다는 것은 수많은 선택적 속성(?)을 가지는 반환 타입을 가지고 있거나 유니온 타입을 사용하는 경우를 말한다.
  • 유니온 타입의 요소별 분기를 위한 방법: 좌표를 위한 기본 형식을 구분한다.
    • e.g. 배열과 배열 같은 것: LngLat, LngLatLike
  • 완전하게 정의된 버전과 부분적으로 정의된 부분을 구분한다.
    • 완전하게 정의된 버전: Camera
    • 부분적으로 정의된 버전: Camera option
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
type Feature = any;
declare function calculateBoundingBox(
f: Feature
): [number, number, number, number];
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;
function focusOnFeature(f: Feature) {
const bounds = calculateBoundingBox(f);
const camera = viewportForBounds(bounds);
setCamera(camera);
const {
center: { lat, lng },
zoom,
} = camera; // OK
zoom; // Type is number
window.location.search = `?v=@${lat},${lng}z${zoom}`;
}

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

코드에 대한 정보가 주석으로 남아있을 때 그 주석의 정보와 코드가 맞이 않을 때가 있다. 타입스크립트의 타입 구문 시스템은 간결하고 구체적이며, 쉽게 읽을 수 있도록 설계되었기 때문에 코드에 대한 설명 및 타입 정보를 주석으로 남기기 보다 타입스크립트의 타입 구문을 사용하도록 한다. 타입 구문은 타입스크립트 컴파일러가 체크해 주기 때문에 구현체와의 정합성이 어긋나지도 않는다. 또 주석은 누군가 고치기 전에 강제로 동기화 되지 않는다. 타입스크립트는 타입 체커가 타입 정보를 동기화하도록 강제한다.

타입스크립트는 명시적으로 사용하는 것이 좋다. ageNum이라는 변수를 선언하는 것보다 age로 변수 선언 후 타입은 num임을 명시하는 것이 더 좋다. 단 단위가 있는 숫자들은 단위가 무엇인지 확실하지 않다면 변수명 또는 속성 이름에 단위를 포한한다. 예를 들어 temperature보다 temperatureC가 훨씬 명확하다.


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

어떤 변수가 null인지 아닌지를 분명히 해야한다. null과 null이 아닌 값을 섞어서 사용하면 문제가 생긴다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function extent(nums: number[]) {
let min, max;
for (const num of nums) {
if (!min) {
min = num;
max = num;
} else {
min = Math.min(min, num);
max = Math.max(max, num);
// ~~~ Argument of type 'number | undefined' is not
// assignable to parameter of type 'number'
}
}
return [min, max];
}
const [min, max] = extent([0, 1, 2]);
const span = max - min;
// ~~~ ~~~ Object is possibly 'undefined'

위 예제에서 extent의 반환값이 (number | undefined)[]로 추론된다. 이렇게 되면 extent를 호출하는 곳마다 타입 오류의 형태로 나타난다. 더 나은 해법으로는 min, max를 한 객체 안에 넣고 null이거나 null이 아니게 하는 아래와 같은 방법으로 작성한다.

1
2
3
4
5
6
7
8
9
10
11
function extent(nums: number[]) {
let result: [number, number] | null = null;
for (const num of nums) {
if (!result) {
result = [num, num];
} else {
result = [Math.min(num, result[0]), Math.max(num, result[1])];
}
}
return result;
}

TL;DR

  • 한 값의 null 여부가 다른 값의 null 여부에 암시적으로 관련되도록 설계하면 안된다.
  • API 작성 시에는 반환 타입을 큰 객체로 만들고 반환 타입 전체가 null이거나 null이 아니게 만들어야 한다.
  • 클래스를 만들 때는 필요한 모든 값이 준비되었을 때 생성하여 null이 존재하지 않도록 한다.
  • strictNullChecks를 설정하면 코드에 많은 오류가 표시되갰지만, null 값과 관련된 문제점을 찾아낼 수 있기 때문에 반드시 필요하다.

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

유니온 타입의 속성을 가지는 인터페이스를 작성 중이라면, 인터페이스의 유니온 타입을 사용하는 게 더 알맞지 않을지 검토해보아야 한다.

1
2
3
4
interface Layer {
layout: FillLayout | LineLayout | PointLayout;
paint: FillPaint | LinePaint | PointPaint;
}

위의 예제는 벡터를 그리는 프로그램을 작성 중이고, 특정한 기하학적 타입을 가지는 계층의 인터페이스를 정의한다고 가정하는 코드이다. 그런데 이 코드에서 layout이 LineLayout(직선)이면서 paint 속성이 FillPaint타입이 되는 조합은 성립하지 않는다. 그런데 위의 코드는 그런 조합을 허용하는 코드이기 때문에 오류가 발생하기 쉽다. 이런 상황에서는 layout과 paint 속성이 지금처럼 잘못된 조합으로 섞이지 않도록 아래와 같이 각각 타입의 계층을 분리된 인터페이스로 작성하도록 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
interface FillLayer {
layout: FillLayout;
paint: FillPaint;
}
interface LineLayer {
layout: LineLayout;
paint: LinePaint;
}
interface PointLayer {
layout: PointLayout;
paint: PointPaint;
}
type Layer = FillLayer | LineLayer | PointLayer;

이렇게 작성하면 유효한 상태만을 표현하게 된다.

1
2
3
4
5
interface Layer {
type: 'fill' | 'line' | 'point';
layout: FillLayout | LineLayout | PointLayout;
paint: FillPaint | LinePaint | PointPaint;
}

위와 같은 태그된 유니온에서도 유효하지 못한 상태가 섞이는 문제가 발생한다. 태그된 유니온은 여러개의 타입을 유니온으로 선언할 때 각 타입에 태그가 있어서 이것으로 구분하는 것을 말한다. 여기서 Layer 속성 중 하나는 문자열 타입의 유니온인데, 이 역시 type: ‘fill’일 때 LineLayout과 PointPaint 타입이 함께 쓰이는 것은 유효하지 않다. 그래서 아래와 같이 Layer의 인터페이스를 유니온으로 변환하는 방식으로 개선할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface FillLayer {
type: 'fill';
layout: FillLayout;
paint: FillPaint;
}
interface LineLayer {
type: 'line';
layout: LineLayout;
paint: LinePaint;
}
interface PointLayer {
type: 'paint';
layout: PointLayout;
paint: PointPaint;
}
type Layer = FillLayer | LineLayer | PointLayer;

이와 같이 어떤 데이터 타입을 태그된 유니온으로 표현할 수 있다면 그렇게 하는 것이 좋다. 특히 여러 개의 선택적 필드가 동시에 값이 있거나 동시에 undefined일 때 이 패턴이 적절하다.

1
2
3
4
5
6
interface Person {
name: string;
// These will either both be present or not be present
placeOfBirth?: string;
dateOfBirth?: Date;
}

위와 같이 주석으로 타입에 대한 정보를 남기 코드가 있다고 했을 때, 이 타입 정보는 placeOfBirth와 dateOfBirth의 관계가 정확하게 표현되어 있지 않다. 이런 경우에는 아래와 같이 타입의 구조를 변경하여 두 개의 속성을 하나의 객체로 모으는 것이 더 나은 설계이다. 이는 null 값을 경계로 두는 아이템 31의 방법과 비슷하다.

1
2
3
4
5
6
7
interface Person {
name: string;
birth?: {
place: string;
date: Date;
};
}

하지만 타입의 구조를 손 댈 수 없는 경우라면(e.g.API response) 아래와 같이 인터페이스의 유니온을 사용해서 속성 사이의 관계를 모델링한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface Person {
name: string;
birth?: {
place: string;
date: Date;
};
}

interface PersonWithBirth extends Name {
placeOfBirth: string;
dateOfBirth: Date;
}

type Person = Name | PersonWithBirth;

TL;DR

  • 유니온 타입의 속성을 여러 개 가지는 인터페이스에서는 속성 간의 관계가 분명하지 않기 때문에 실수가 자주 발생하므로 주의하도록 한다.
  • 유니온의 인터페이스보다 인터페이스의 유니온이 더 정확하고 타입스크립트가 이해하기 좋다.
  • 타입스크립트가 제어된 흐름을 분석할 수 있도록 타입에 태그를 넣는 것을 고려하도록 한다. 태그된 유니온은 타입스트립트와 매우 잘 맞아서 자주 볼 수 있는 패턴이다.

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

string 타입의 범위는 매우 넓어서 ‘x’와 같은 한 글자와 , ‘call me ishmael….’로 시작하는 모비딕의 전체 내용과 같이 긴 텍스트도 string 타입이다. 그러므로 타입으로 변수를 선언 할 때, 더 좁은 타입이 적절하지 않을 지 검토해보아야 한다. string은 any와 비슷한 문제를 가지고 있어서 잘못 사용하면 무효한 값을 허용하고, 타입 간의 관계를 감추어 버리기 때문이다.

1
2
3
4
5
6
7
8
9
10
11
12
interface Album {
artist: string;
title: string;
releaseDate: string; // YYYY-MM-DD
recordingType: string; // E.g., "live" or "studio"
}
const kindOfBlue: Album = {
artist: 'Miles Davis',
title: 'Kind of Blue',
releaseDate: 'August 17th, 1959', // Oops!
recordingType: 'Studio', // Oops!
}; // OK

위의 Album은 string이 남발되어 타입이 모델링 되었다.(stringly typed) 이 경우 releaseDate, recordinType이 Album에 정의된 주석과 다른 형태로 모델링되었지만 string이기 때문에 타입 체커를 통과한다. 아래와 같이 함수 호출시 매개변수의 순서가 바뀐 경우에도 둘 다 문자열이기 때문에 타입 체커를 통과한다.

1
2
3
4
function recordRelease(title: string, date: string) {
/* ... */
}
recordRelease(kindOfBlue.releaseDate, kindOfBlue.title); // OK, should be error

이런 경우 아래와 같이 타입을 좁히는 방식으로 개선할 수 있다. releaseDate는 날짜형식으로 제한하고, recordingType은 두 개의 값을 가진 유니온 타입으로 정의할 수 있다.

1
2
3
4
5
6
7
8
type RecordingType = 'studio' | 'live';

interface Album {
artist: string;
title: string;
releaseDate: Date;
recordingType: RecordingType;
}

이렇게 작성하면 앞서 오류가 발생할 상황에서도 타입체커를 통과했던 것과 다르게 타입스크립트가 타입 체커를 세밀하게 체크할 수 있어서 타입 오류를 잘 검사하게 된다.

string 타입 좁히기의 장점

  • 타입을 명시적으로 정의하여 다른 곳으로 값이 전달되어도 타입 정보가 유지된다.
1
2
3
4
5
6
//recordingType 매개변수에 대한 타입을 RecordingType과 같이 사용하면 편집기에서 자동완성을 통해 타입에 대한 설명을 확인할 수 있다.
function getAlbumsOfType(recordingType: RecordingType): Album[] {
// COMPRESS
return [];
// END
}
  • 타입을 명시적으로 정의히고, 해당 타입의 의미를 설명하는 주석을 함께 쓸 수 있다.
1
2
/** What type of environment was this recording made in?  */
type RecordingType = 'live' | 'studio';
  • keyof 연산자로 더욱 세밀하게 객체의 속성 체크가 가능하다.
      1. underscore 라이브러리의 pluck함수의 시그니처를 작성한다.
1
2
3
function pluck(record: any[], key: string): any[] {
return record.map((r) => r[key]);
}
  • 2.타입 체크가 되지만 정밀하지 못하다. 반환값에 any를 쓰는 것도 바람직 하지 않다.
1
2
3
4
5
6
//제너릭 타입을 도입하여 개선한다.하지만 매개변수가 string이므로 오류를 발생시킨다.
function pluck<T>(record: T[], key: string): any[] {
return record.map((r) => r[key]);
// ~~~~~~ Element implicitly has an 'any' type
// because type '{}' has no index signature
}
  • 3.매개변수로 Album의 배열을 전달했기 때문에 string이었던 타입이 type k와 같이 좁혀졌다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
type RecordingType = 'studio' | 'live';

interface Album {
artist: string;
title: string;
releaseDate: Date;
recordingType: RecordingType;
}
type K = keyof Album;
// Type is "artist" | "title" | "releaseDate" | "recordingType"

function pluck<T>(record: T[], key: keyof T) {
return record.map((r) => r[key]);
}
  1. keyof T의 부분 집합(아마도 단일값)으로 두 번째 제너릭 매개변수를 도입한다.
1
2
3
4
//T[key of]T는 T 객체 내에 가능한 모든 값의 타입이라서 string보다는 범위가 좁지만 여전히 넓다.
//또 key에 문자열 하나를 넣게 되면 그에 비해 범위는 너무 넓어서 적절하지 않다.
declare let albums: Album[];
const releaseDates = pluck(albums, 'releaseDate'); // Type is (string | Date)[]

아래와 같이 두 번째 제너릭 매개변수를 도입한다.

1
2
3
function pluck<T, K extends keyof T>(record: T[], key: K): T[K][] {
return record.map((r) => r[key]);
}

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

타입이 구체적일수록 버그를 더 잘 잡고, 타입스크립트가 제공하는 도구를 활용할 수 있는데, 잘못된 타입은 차라리 타입이 없는 것만 못하기 때문에 주의를 기울여야 한다.


References
[이펙티브 타입스크립트] 댄 밴더캄 지음

아이템 21 타입 넓히기

런타임에 모든 변수는 유일한 을 가지고, 타입스크립트가 코드를 체크하는 정적 분석 시점에 변수는 가능한 값들의 집합인 타입을 가진다. 그래서 상수를 사용해서 변수를 초기화할 때 타입을 명시하지 않으면 타입 체커가 타입을 결정해야 한다. 즉 지정된 단일 값을 가지고 할당 가능한 값들의 집합을 유추해야 한다.는 뜻이다. 그리고 이를 넓히기(widening)라고 부른다.

1
2
3
4
5
6
7
8
9
const mixed = ['x', 1];

//추론 가능한 타입
('x' | 1)[]
['x',1]
[string, number]
(string|number)[]

//생략

위 예제에서 mixed는 추론 가능한 타입이 여러개이다. 만약 mixed에 대한 타입이 충분하지 않다면 어떤 타입으로 추론되어야 하는지 할 수 없어서 타입스크립트는 작성자의 의도를 추측한다.

넓히기 과정을 제어하는 방법

  • const의 사용 : let 대신 const로 변수를 선언한다. const는 재할당이 불가하기 때문에 타입스크립트가 추론 가능한 타입이 여러개여서 모호한 과정에 빠지지 않는다. 다만 객체와 배열의 경우 const를 써도 문제가 있다.
1
2
3
4
5
6
7
8
9
10
const v = {
x: 1
}
v.x = 3 // OK
v.x = '3'
// ~ Type '"3"' is not assignable to type 'number'
v.y = 4
// ~ Property 'y' does not exist on type '{ x: number; }'
v.name = 'Pythagoras'
// ~~~~ Property 'name' does not exist on type '{ x: number; }'

타입스크립트는 명확성과 유연성 사이의 균형을 유지하려고 한다. 그래서 구체적으로 타입을 추론해야 하지만, 잘못된 추론을 할 정도로 구체적으로 수행하진 않는다. 만약 타입 추론의 강도를 직접 제어하려면 타입스크립트의 기본 동작을 재정의한다.

타입스크립트의 기본 동작 제어방법

  • 명시적 타입 구문 제공
  • 타입 체커에 추가적인 문맥 제공
  • const 단언만 사용
    • 이 const는 변수 선언에 쓰이는 const와 가른 것이므로 헷갈려서는 안된다. const 단언문은 온전히 타입 공간의 기법이다.
    • 아래 예제에서 값 뒤에 as const를 작성하면 최대한 좁은 타입으로 추론하기 때문에 v3에서 넓히기가 동작하지 않는다.
    • 배열을 튜플로 추론할 때도 as const를 쓸 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
interface Vector3 {
x: number
y: number
z: number
}
function getComponent(vector: Vector3, axis: 'x' | 'y' | 'z') {
return vector[axis]
}
const v1 = {
x: 1,
y: 2
} // Type is { x: number; y: number; }

const v2 = {
x: 1 as const,
y: 2
} // Type is { x: 1; y: number; }

const v3 = {
x: 1,
y: 2
} as const // Type is { readonly x: 1; readonly y: 2; }

아이템 22 타입 좁히기

타입 넓히기의 반대개념이다. 타입스크립트가 넓은 타입으로부터 좁은 타입으로 진행하는 과정을 말한다.

  • null 체크
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//1️⃣
const el = document.getElementById('foo') // Type is HTMLElement | null
if (el) {
el // Type is HTMLElement
el.innerHTML = 'Party Time'.blink()
} else {
el // Type is null
alert('No element #foo')
}

//2️⃣
const el = document.getElementById('foo') // Type is HTMLElement | null
if (!el) throw new Error('Unable to find #foo')
el // Now type is HTMLElement
el.innerHTML = 'Party Time'.blink()

위 예시에서 1️⃣의 el이 null이면 첫번 째 블록의 const el = document.getElementById(‘foo’); // Type is HTMLElement | null 을 실행하지 않기 때문에 Type is HTMLElement | null에서 null을 제외하므로 더 좁은 타입이 된다. 2️⃣와 같이 분기문에서 예외를 던지거나 함수를 반환해서 블록의 나머지 부분에서 변수의 타입을 좁힐 수도 있다.

  • instanceof사용
  • 속성 체크
  • Array.isArray 사용
  • 조건문 사용
1
2
3
4
const el = document.getElementById('foo') // type is HTMLElement | null
if (typeof el === 'object') {
el // Type is HTMLElement | null
}

조건문이 가장 타입을 좁히는 데 능숙한 방법이지만 위와 같은 예제처럼 실수를 일으키기 쉽다. 위 예제에서 typeof null은 object이기 때문에 if문에서 null이 제외되지 않는다. 다음과 같은 기본형에서도 기본형 값이 잘못되어도 제대로 좁혀지지 않는다. 빈 문자열 ‘’와 0 모두 false가 되기 때문에 좁혀지지 않는다.

1
2
3
4
5
function foo(x?: number | string | null) {
if (!x) {
x // Type is string | number | null | undefined
}
}
  • 명시적 태그를 붙이기 (태그된 유니온 또는 구별된 유니온
  • 사용자 정의 타입 가드 사용: 타입스크립트가 타입을 식별하지 못할 때 커스텀 함수를 도입할 수 있다.
  • 타입 가드 사용
1
2
3
4
5
6
7
const jackson5 = ['Jackie', 'Tito', 'Jermaine', 'Marlon', 'Michael']
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) // Type is string[]

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

타입스크립트의 타입은 변경되지 않기 때문에 객체를 생성할 때는 속성을 하나씩 추가하는 것 보다 여러 속성을 포함해서 한꺼번에 생성해야 타입 추론에 유리하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//에러 발생
const pt = {}
pt.x = 3
// ~ Property 'x' does not exist on type '{}'
pt.y = 4
// ~ Property 'y' does not exist on type '{}'

//에러 발생
interface Point {
x: number
y: number
}
const pt: Point = {}
// ~~ Type '{}' is missing the following properties from type 'Point': x, y
pt.x = 3
pt.y = 4

//통과 - 객체 한꺼번에 생성
interface Point {
x: number
y: number
}
const pt = {
x: 3,
y: 4
} // OK

객체를 반드시 제각각 나눠서 만들어야 한다면 타입 단언문(as)를 사용한다. 그러나 객체를 한꺼번에 만드는 것이 더욱 권장된다. 작은 객체들을 조합해서 큰 객체를 만들어야 할 때는 전개 연산자를 사용해서 객체를 한꺼번에 만든다. 이렇게 하면 타입 걱정 없이 필드 단위로 객체를 생성할 수 있는데 이때 중요한 점은 모든 업데이트마다 새 변수를 사용하여 새로운 타입을 얻도록 하는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//전개 연산자 사용
interface Point {
x: number
y: number
}
const pt = { x: 3, y: 4 }
const id = { name: 'Pythagoras' }
const namedPoint = { ...pt, ...id }
namedPoint.name // OK, type is string

//전개 연산자를 사용할 때 모든 업데이트 시
//새 변수를 사용하여 새로운 타입을 얻도록 한다.
interface Point {
x: number
y: number
}
const pt0 = {}
const pt1 = { ...pt0, x: 3 }
const pt: Point = { ...pt1, y: 4 } // OK

타입에 안전한 방식으로 조건부 속성을 추가하려면 속성을 추가하지 않는 null 또는 {}로 객체 전개를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
//1️⃣ 조건부 속성의 추가 - {}
declare let hasMiddle: boolean
const firstLast = { first: 'Harry', last: 'Truman' }
const president = { ...firstLast, ...(hasMiddle ? { middle: 'S' } : {}) }

// 2️⃣전개 연산자로 한꺼번에 여러 속성을 추가한다.
declare let hasDates: boolean
const nameTitle = { first: 'Harry', last: 'Truman' }
const pharaoh = {
...nameTitle,
...(hasDates ? { start: -289, end: -22 } : {})
}

2️⃣의 타입은 유니온으로 추론된다.

1
2
3
4
5
6
7
8
const pharaoh:
{
start: number;
end: number;
name: string;
title: string;
}
| { name: string; title: string };

이 경우 pharaoh.start로 접근하면 start와 end는 항상 함께 정의되기 때문에 속성이 없다는 에러가 표시된다. 따라서 유니온을 사용하는 것이 가능한 값의 집합을 더 정확히 표현하는 것이다. 다만 유니온보다 선택적 필드가 다루기에 더 쉬울 수 있다. 아래와 같이 헬퍼 함수를 사용해서 표현한다.

1
2
3
4
5
6
7
8
9
10
11
declare let hasMiddle: boolean
const firstLast = { first: 'Harry', last: 'Truman' }
function addOptional<T extends object, U extends object>(
a: T,
b: U | null
): T & Partial<U> {
return { ...a, ...b }
}

const president = addOptional(firstLast, hasMiddle ? { middle: 'S' } : null)
president.middle // OK, type is string | undefined

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

별칭의 값을 변경하면 원래 속성값에서도 변경된다.

1
2
3
4
5
const borough = { name: 'Brooklyn', location: [40.688, -73.979] }
const loc = borough.location

loc[0] = 0
borough.location // [0, -73.979]

이렇게 별칭을 사용할 때 남발해서 사용하면 제어 흐름을 분석하기 어렵다. 제어 흐름을 분석하기 어려워서 오류가 발생할 수 있는데, 별칭은 일관성있게 사용한다는 기본 원칙을 지키면 오류를 방지할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
interface Coordinate {
x: number
y: number
}

interface BoundingBox {
x: [number, number]
y: [number, number]
}

interface Polygon {
exterior: Coordinate[]
holes: Coordinate[][]
bbox?: BoundingBox
}

//객체 비구조화 사용
function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
const { bbox } = polygon
if (bbox) {
const { x, y } = bbox
if (pt.x < x[0] || pt.x > x[1] || pt.y < x[0] || pt.y > y[1]) {
return false
}
}
// ...
}

위와 같이 별칭 대신 객체 비구조화를 사용해서 일관된 이름을 사용할 수 있는데, 배열과 중첩된 구조에서도 사용 가능하다. 다만 아래의 두 가지 문제를 주의한다.

  • 전체 bbox 속성이 아니라 x와 y가 선택적 속성일 경우 속성 체크가 더 필요하다. 따라서 타입의 경계에 null 값을 추가하는 것이 좋다.
  • bbox에는 선택적 속성이 적합했지만 holes에는 그렇지 않다. holes가 선택적이라면 값이 없거나 빈 배열이었을 것이다. 차이가 없는데 이름을 구별한 것이다. 빈 배열은 ‘holes없음’을 나타내는 좋은 방법이다.

별칭은 타입 체커 뿐 아니라 런타임에도 혼동을 야기할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
interface Coordinate {
x: number
y: number
}

interface BoundingBox {
x: [number, number]
y: [number, number]
}

interface Polygon {
exterior: Coordinate[]
holes: Coordinate[][]
bbox?: BoundingBox
}
//1️⃣ 런타임에 혼동을 야기한다.
// HIDE
const polygon: Polygon = { exterior: [], holes: [] }
function calculatePolygonBbox(polygon: Polygon) {}
// END
const { bbox } = polygon
if (!bbox) {
calculatePolygonBbox(polygon) // Fills in polygon.bbox
// Now polygon.bbox and bbox refer to different values!
}

//2️⃣ 객체 속성에 대해 주의한다.
function fn(p: Polygon) {
/* ... */
}

polygon.bbox // Type is BoundingBox | undefined
if (polygon.bbox) {
polygon.bbox // Type is BoundingBox
fn(polygon)
polygon.bbox // Type is still BoundingBox
}

2️⃣의 경우 fn(polygon)을 호출하면 polygon.bbox를 제거할 가능성이 있다. 따라서 타입은 BoundingBox | undefined로 되돌리는 것이 안전하다. 그러나 함수를 호출할 때마나 속성 체크를 반복해야한다는 문제가 있다. 그래서 타입스크립트는 함수가 타입 정제를 무효화하지 않는다고 가정하지만 실제로는 무효화될 가능성이 있다.

TL;DR

  • 별칭은 타입스크립트가 타입을 좁히는 것을 방해한다. 따라서 변수에 별칭을 사용할 때 일관되게 사용해야 한다.
  • 비구조화 문법을 사용해서 일관된 이름을 사용하는 것이 좋다.
  • 함수 호출이 객체 속성의 타입 정제를 무효화하 수 있다는 점에 주의한다. 속성보다 지역 변수를 사용하면 타입 정제를 믿을 수 있다.

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

자바스크립트는 비동기 동작을 모델링하기 위해 콜백 패턴을 사용했는데, 필연적으로 콜백 지옥을 마주하여 직관적으로 코드를 이해하기 어려운 상황에 부딪히게 된다. 이를 극복하기 위해 프로미스, async await 키워드가 도입되어서 콜백 지옥을 간단하게 해결할 수 있게 되었다. ES5 또는 그 이전 버전을 대상으로 할 때 타입스크립트 컴파일러는 async와 await가 동작하도록 정교한 변환을 수행한다. 즉 런타임에 관계없이 async/await를 사용할 수 있다. async 함수는 항상 프로미스를 반환하도록 강제된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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'
}

위 코드에서 async/await를 사용했기 때문에 requestStatus가 ‘success’로 끝나는 것이 명백해졌다. 콜백이나 프로미스를 사용하면 의도치한게 동기코드를 작성하게 되는 것처럼 실수로 반(half)동기 코드를 작성할 수 있지만 async를 사용하면 항상 비동기 코드를 작성하게 된다. 또한 async함수에서 프로미스를 반환하면 또 다른 프로미스로 래핑되지 않기 때문에 Promise<Promise<T>>가 아닌 Promise<T>가 된다.

TL;DR

  • 콜백보다는 프로미스를 사용하는 게 코드 작성과 타입 추론 면에서 유리하다.
  • 가능하면 프로미스를 생성하기보다 async와 await를 사용하는 것이 좋다. 간결하고 직관적인 코드를 작성할 수 있고 모든 종류의 오류를 제거할 수 있기 때문이다.
  • 어떤 함수가 프로미스를 반환한다면 async로 선언하는 것이 좋다.

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

타입스크립트는 타입 추론 시 값만 고려하는 것이 아니라 그 값이 존재하는 곳의 문맥도 살핀다. 그렇기 때문에 가끔 이상한 결과가 나오기도 해서 타입 추론에 문맥이 어떻게 사용되는지 이해하는 것이 중요하다.

자바스크립트는 코드의 동작과 실행 순서를 바꾸지 않으면서 표현식을 상수로 분리해 낼 수 있다.

1
2
3
4
5
6
7
8
function setLanguage(language: string) {
/* ... */
}

setLanguage('JavaScript') // OK, 인라인 형태

let language = 'JavaScript'
setLanguage(language) // OK, 참조 형태

인라인 형태의 타입스크립트는 함수 선언을 통해 매개변수가 language 타입이어야 한다는 것을 알고 있다. 타입스크립트는 일반적으로 값이 처음 등장할 때 타입을 결정하기 때문이다. 그러나 이 값으르 변수로 분리해내면 타입스크립트는 할당 시점에 타입을 추론한다. 그래서 아래와 같이 string으로 추론하고, Language 타입에 할당이 불가능해서 오류가 발생한다.

1
2
3
4
5
6
7
8
9
10
11
type Language = 'JavaScript' | 'TypeScript' | 'Python'
function setLanguage(language: Language) {
/* ... */
}

setLanguage('JavaScript') // OK

let language = 'JavaScript'
setLanguage(language)
// ~~~~~~~~ Argument of type 'string' is not assignable
// to parameter of type 'Language'

해결 방법

  • 타입 선언에서 language의 가능한 값을 제한한다.
  • language를 상수로 만든다.
1
2
3
4
5
6
7
//타입 선언에서 language의 가능한 값을 제한한다.
let language: Language = 'JavaScript'
setLanguage(language) //정상

//language를 상수로 만든다.
const language = 'JavaScript'
setLanguage(language) //정상

튜플 사용 시 주의점

튜플은 요소의 타입과 개수가 고정된 배열을 표현할 수 있는 타입이다. 따라서 아래와 같이 사용하면 에러가 발생한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Language = 'JavaScript' | 'TypeScript' | 'Python'
function setLanguage(language: Language) {
/* ... */
}
// Parameter is a (latitude, longitude) pair.
function panTo(where: [number, number]) {
/* ... */
}

panTo([10, 20]) // OK

const loc = [10, 20]
panTo(loc)
// ~~~ Argument of type 'number[]' is not assignable to
// parameter of type '[number, number]'

loc로 선언하여서 타입이 number[]로 추론된다. 이는 길이를 알 수 없는 숫자의 배열이어서 [10,20]과 맞지 않는 수의 요소이기 때문에 튜플에 할당할 수 없다. 이 에러를 해결하는 방법은 다음과 같다.

  • 타입스크립트가 의도를 정확히 파악할 수 있도록 타입 선언 제공
  • 상수 문맥 제공: as const로 값이 가리키는 참조와 그 값이 내부까지 상수임을 알려준다. 단 as const는 타입 정의에 실수가 있을 때 타입 정의가 아니라 호출되는 곳에서 에러가 발생해서 근본적인 원인을 찾기 어렵게 한다.
  • readonly의 사용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
type Language = 'JavaScript' | 'TypeScript' | 'Python'
function setLanguage(language: Language) {
/* ... */
}
// Parameter is a (latitude, longitude) pair.
function panTo(where: [number, number]) {
/* ... */
}

//1️⃣ 타입 선언
const loc: [number, number] = [10, 20]
panTo(loc) // OK

//2️⃣ as const로 상수 문맥 제공 - 이 케이스에서는 에러발생
function panTo(where: [number, number]) {
/* ... */
}
const loc = [10, 20] as const
panTo(loc)
// ~~~ Type 'readonly [10, 20]' is 'readonly'
// and cannot be assigned to the mutable type '[number, number]'

//3️⃣ 타입 시그니처에 readonly 사용
function panTo(where: readonly [number, number]) {
/* ... */
}
const loc = [10, 20] as const
panTo(loc) // OK

객체 사용 시 주의점

객체 사용 시에도 에러를 발생시키는 문제가 생기는데 아래와 같은 방법으로 해결한다.

  • 타입 선언 추가
  • 상수 단언(as const)사용

콜백 사용 시 주의점

콜백을 다른 함수로 전달할 때 콜백의 매개변수 타입을 추론하기 위해 문맥을 사용한다. 그런데 아래 예시에서 fn으로 콜백을 상수로 뽑아내면 문맥이 소실되어 noImplicitAny오류가 발생한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function callWithRandomNumbers(fn: (n1: number, n2: number) => void) {
fn(Math.random(), Math.random())
}
const fn = (a, b) => {
// ~ Parameter 'a' implicitly has an 'any' type
// ~ Parameter 'b' implicitly has an 'any' type
console.log(a + b)
}
callWithRandomNumbers(fn)

//해결방법
const fn = (a: number, b: number) => {
console.log(a + b)
}
callWithRandomNumbers(fn)

해결방법은 아래와 같다.

  • 매개변수에 타입 구문을 추가한다.
  • 가능하다면 전체 함수 표현식에 타입 선언

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

로대시(lodash)와 같은 라이브러리의 일부 기능은 순수 자바스크립트로 구현되어 있고, 루프를 대체할 수 있기 때문에 유용하게 사용되고, 타입스크립트와 조합했을 때 더욱 유용하게 사용된다. 타입 정보는 유지하면서 타입 흐름이 계속 전달되도록 하기 때문이다. 하지만 서드파티 라이브러리 기반으로 코드를 짧게 줄이는데 시간이 많이 든다면 사용하지 않는게 낫다.

1
2
3
4
5
6
7
8
9
10
11
12
13
const rows = rawRows
.slice(1)
.map(rowStr =>
rowStr
.split(',')
.reduce((row, val, i) => ((row[headers[i]] = val), row), {})
)

//lodash zipObject사용
import _ from 'lodash'
const rows = rawRows
.slice(1)
.map(rowStr => _.zipObject(headers, rowStr.split(',')))

References
[이펙티브 타입스크립트] 댄 밴더캄 지음

아이템 19 추론 가능한 타입을 사용해 장황한 코드 방지하기

타입스크립트가 결국 타입을 위한 언어이기 때문에 변수를 선언할 때마다 타입을 명시해야 한다고 생각하기 쉽다. 하지만 코드의 모든 변수에 타입을 선언하는 것은 비생산적이다. 타입스크립트는 타입 추론이 된다면 명시적 타입 구문은 필요하지 않다. let x = 12;와 같은 구문은 x가 number로 추론되기 때문에 굳이 let x: number = 12;로 작성하지 않아도 되고, 객체와 배열에 대해서도 동일하다. 아래 예시에서 1️⃣은 2️⃣로 작성해도 동일하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//1️⃣
const person: {
name: string;
born: {
where: string;
when: string;
};
died: {
where: string;
when: string;
};
} = {
name: 'Sojourner Truth',
born: {
where: 'Swartekill, NY',
when: 'c.1797',
},
died: {
where: 'Battle Creek, MI',
when: 'Nov. 26, 1883',
},
};

//2️⃣
const person = {
name: 'Sojourner Truth',
born: {
where: 'Swartekill, NY',
when: 'c.1797',
},
died: {
where: 'Battle Creek, MI',
when: 'Nov. 26, 1883',
},
};

때로는 추론이 더 정확할 때가 있는데, 아래의 경우 명시적으로 string이라고 타입을 준 것 보다 추론된 ‘y’가 사실은 더 정확하다.

1
2
const axis1: string = 'x'; // Type is string
const axis2 = 'y'; // Type is "y"

아래 예시에서 Product의 id를 number라고 작성했다가 나중에 문자도 있을 수 있다는 것을 알게되어 string으로 작성했다고 가정해보자. 이 경우 선언된 타입과 함수 내의 타입이 일치하지 않아서 오류를 발생시킨다. 만약 여기서 명시적 타입 구문이 없었다면 문제없이 타입 체커를 통과했을 것이다. 그래서 이런 경우에는 비구조 할당문으로 구현하는 것이 더 나은 선택이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
interface Product {
id: string;
name: string;
price: number;
}

function logProduct(product: Product) {
const id: number = product.id;
// ~~ Type 'string' is not assignable to type 'number'
const name: string = product.name;
const price: number = product.price;
console.log(id, name, price);
}

//비구조 할당 - 여기에 추가로 명시적 타입 구문을 넣는 것은 불필요하다.
interface Product {
id: string;
name: string;
price: number;
}
function logProduct(product: Product) {
const { id, name, price } = product;
console.log(id, name, price);
}

그러나 정보가 부족해서 타입스크립트가 스스로 판단하기 어려운 경우에는 명시적 타입 구문이 필요하다. 위 예제에서 logProduct 함수에서 매개밴수의 타입을 Product로 명시한 경우가 그 예이다. 이상적인 타입스크립트 코드는 함수/메서드 시그니처에 타입 구문을 포함하지만, 함수 내에서 생성된 지역변수에는 타입 구문을 넣지 않는 것이다. 단, 기본값이 있는 경우에는 타입 구문을 생략하기도 한다.

1
2
3
function parseNumber(str: string, base = 10) {
// ...
}

타입이 추론될 수 있음에도 타입을 명시하고 싶은 경우가 있다. 객체 리터럴의 정의과 함수의 반환 타입을 명시할 때이다.

객체 리터럴의 정의

객체 리터럴에서 타입을 명시하면 잉여 속성 체크가 동작해서 실제로 실수가 방생한 부분에 정확하게 오류를 표시해 줄 수 있다. 아래 예시에서 타입 구문을 제거하면 잉여 속성 체크가 동작하지 않아서 실제 오류가 발생한 id쪽이 아니라 객체가 사용되는 곳에서 오류가 발생한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
interface Product {
id: string;
name: string;
price: number;
}

function logProduct(product: Product) {
const id: number = product.id;
// ~~ Type 'string' is not assignable to type 'number'
const name: string = product.name;
const price: number = product.price;
console.log(id, name, price);
}
const furby: Product = {
name: 'Furby',
id: 630509430963,
// ~~ Type 'number' is not assignable to type 'string'
price: 35,
};
logProduct(furby);

함수의 반환

타입 추론이 가능한 경우에도 구현상의 오류가 함수를 호출한 곳까지 영향을 미치지 않도록 하기 위함이다. 반환 타입을 명시하면 구현상의 오류가 사용자 코드의 오류로 표시되지 않고, 오류의 위치를 제대로 표시해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const cache: { [ticker: string]: number } = {};
function getQuote(ticker: string) {
//getQuote가 반환하는 것은 Promise.resolve(cache[ticker])이어야 한다.
//따라서 여기에서 아래와 같은 오류가 발생해야 한다.
//~~~~~~~~~~~~~ Type 'number' is not assignable to 'Promise<number>'
//하지만 실제 오류는 가장 아래 getQuote를 호출한 코드에서 발생한다.
if (ticker in cache) {
return cache[ticker];
}
return fetch(`https://quotes.example.com/?q=${ticker}`)
.then((response) => response.json())
.then((quote) => {
cache[ticker] = quote;
return quote;
});
}
function considerBuying(x: any) {}
getQuote('MSFT').then(considerBuying);
// ~~~~ Property 'then' does not exist on type
// 'number | Promise<any>'
// Property 'then' does not exist on type 'number'

그 외에도 반환타입을 명시하는 것은 아래 두 가지 장점이 있다.

  • 함수를 더욱 명확하게 알기 쉽다. 반환 타입을 명시하려면 입력, 출력 타입에 대해 알아야 하고 미리 명시해야만 하기 때문에 타입을 미리 작성하여 구현에 맞추어 주먹구구식으로 타입이 작성되는 것이 아닌 테스트 주도 개발처럼 작성할 수 있게 된다.
  • 명명된 타입을 사용할 수 있다. 반환 타입을 명시하면 더욱 직관적인 표현이 되고, 반환 값을 별도의 타입으로 정의하면 타입에 대한 주석을 작설항 수 있어 함수에 대해 더 자세히 설명하게 된다.

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

자바스크립트에서는 한 변수를 다른 목적을 가지는 다른 타입으로 재사용해도 되는데, 타입스크립트에서는 이렇게 사용하면 두 가지 오류가 발생한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//자바스크립트
let id = '12-34-56';
fetchProduct(id); //string으로 사용

id = 123456;
fetchProductBySerialNumber(id); //number로 사용

//타입스크립트
function fetchProduct(id: string) {}
function fetchProductBySerialNumber(id: number) {}
let id = '12-34-56';
fetchProduct(id);

id = 123456;
// ~~ '123456' is not assignable to type 'string'.
fetchProductBySerialNumber(id);
// ~~ Argument of type 'string' is not assignable to
// parameter of type 'number'

여기서 중요한 점은 변수의 값은 바뀔 수 있지만 타입은 바뀌지 않는다.는 점이다. 범위를 좁히는 방법으로 타입을 바꿀 수는 있지만 그것은 새로운 변수값을 포함하도록 확장하는 것이 아니라 타입을 더 작게 제한하는 것이다.

유니온 타입을 이용한 타입의 확장

id가 string, number를 모두 포함할 수 있도록 타입을 확장하는 유니온을 통해 아래와 같이 작성하면 에러는 해결된다. 하지만 이렇게 작성하면 매번 id값이 string인지 number인지 확인해야 하기 때문에 이런 경우에는 별도의 변수로 작성하는 것이 낫다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function fetchProduct(id: string) {}
function fetchProductBySerialNumber(id: number) {}
let id: string | number = '12-34-56';
fetchProduct(id);

id = 123456; // OK
fetchProductBySerialNumber(id); // OK

//유니온 타입 대신 별도의 변수로 작성
function fetchProduct(id: string) {}
function fetchProductBySerialNumber(id: number) {}
const id = '12-34-56';
fetchProduct(id);

const serial = 123456; // OK
fetchProductBySerialNumber(serial); // OK

무엇보다 변수를 재사용하는 방식은 타입 체커는 물론 사람에게도 혼란을 주기 때문에 지양해야 한다. 타입이 다른 경우 별도의 변수를 사용하는 것이 바람직한 이유는 다음과 같다.

  • 서로 관련이 없는 두 개의 값을 분리한다.
  • 변수명을 더 구체적으로 지을 수 있다.
  • 타입 추론을 향상시키며, 타입 구문이 불필요해진다.
  • 타입이 간결해진다.
  • let 대신 const로 변수를 선언하게 된다. 이렇게 하면 코드가 간결하고, 타입 체커의 타입 추론이 용이하다.

References
[이펙티브 타입스크립트] 댄 밴더캄 지음

사용자를 대표하는 컴퓨터 프로그램, 웹에서는 브라우저를 의미하고, 웹 페이지를 긁어가는 봇, 다운로드 관리자, 웹에 접근하는 다른앱 등이 있다.

  • 브라우저는 서버에 보내는 모든 요청에 User-Agent HTTP(사용자 에이전트 문자열) 헤더를 보낸다.
  • 여기에는 브라우저 종류, 버전 번호, 호스트 운영체제를 포함하는 브라우저 정보가 담겨있다.
  • 클라이언트에서 JS의 navigator.userAgent 속성으로 사용자 에이전트 문자열에 접근할 수 있다.

user agent를 이용한 브라우저 감지

웹은 유저가 어떤 브라우저, 어떤 디바이스를 사용하고 있는지와 관계없이 모두에게 접근성이 용이해야 한다. 하지만 브라우저와 웹 표준이 완벽하지 않기 때문에 몇 가지 edge case가 존재하여 브라우저 감지를 필요로 한다. user agent를 사용하여 브라우저를 감지하는 것은 간단하지만 그것을 잘하기는 매우 어려운 문제이다.

브라우저 감지 전에 고려해야할 점

웬만하면 user agent를 사용한 브라우저 감지를 하지 않는 것이 우선이다. 하지만 그럼에도 불구하고 필요하다고 판단된다면 아래 질문에 근거하여 정말로 내가 왜 그 기능을 필요로 하는가를 고려해보도록 한다.

  • 특정 브라우저 버전에 있는 버그를 고치려고 하는가?
    • 포럼에서 버그를 찾아보고, 처음 발견한 버그라면 질문해보도록 한다. 만약 정상적이지 않은 문제로 보인다면 브라우저 제공자의 버그 추척 시스템(Mozilla, WebKit, Blink, Opera)에 보고된 버그인지 확인해본다.
  • 특정 기능의 존재 여부를 체크하려고 하는가?
    • 몇몇 브라우저에서 지원하지 않는 기능을 사이트에서 사용하고자 할 때, 그 유저들을 기능은 더 적지만 작동할 것임이 분명한 옛 버전의 웹 사이트로 보내고 싶을텐데, 결국에는 언젠가 해당 브라우저에서 그 기능이 동작할 것임을 알고 있는 상황이다. 이런 상황이 user agent를 이용한 브라우저 감지를 사용하는 가장 나쁜 케이스인데, 몇몇 브라우저에서 지원하지 않는 기능들도 결국에는 지원하는 방향이 될 것이기 때문이다. 또한 비교적 인기가 덜한 브라우저의 웹 기능까지 모두 테스트하는 것은 실용적이지 않다. 이런 경우에는 user agent를 사용한 탐지를 절대 피해야 한다. 언제나 기능을 탐지할 수 있는 대안이 존재하기 때문이다.
  • 사용하는 브라우저에 따라 다른 HTML을 제공해야 하는가?
    • 보통 이런 것은 나쁜 방법이지만 필요한 경우가 있다. 필요에 의해 사용해야 한다면 먼저 정말로 이렇게 해야하는지 당신이 처한 상황에 대해 분석해보아야 한다. non-semantic 요소인 <div>, <span> 등을 추가하여 피할 수 있는 방법이 있을까? user Agent 감지를 성공적으로 하는 것의 어려움은 HTML의 순수성을 혼란스럽게 할 수 있다. 또한 디자인에 대해 다시 생각해보아야 한다. 브라우저별로 다른 HTML을 사용할 필요성을 없애기 위해 점진적 향상을 고려하거나 가변 레이아웃(fluid layouts)를 사용할 수 있는가?

user agent 사용을 대신할 방법

모바일 장치 감지

틀림없이 user agent의 가장 흔한 사용 및 오용은 디바이스가 모바일 디바이스인지 여부를 감지하는 것이다. 그러나 사람들은 정말로 해야하는 것이 무엇인지 간과하기 쉽다. 사람들은 user agent를 사용해서 유저의 디바이스가 터치 친화적(touch-friendly)인지 작은 스크린에서도 그에 따라 웹사이트를 최적화할 수 있는지 여부를 감지한다. user agent가 때때로 이런 것을 감지할 수 있지만 모든 디바이스에 대해 동일한 것은 아니다. 일부 모바일 장치는 큰 스크린을 가진 경우가 있고, 일부 데스크톱은 작은 터치스크린을 가진 경우가 있다. 어떤 사람은 smart TV를 쓰기도 하고, 또 어떤 사람들은 태블릿을 옆으로 움직여서 화면의 높이와 너비를 동적으로 변경할 수도 있다. 그러므로 user agent의 사용은 확실히 올바른 방법은 아니다. 그리고 더 좋은 대안이 있다.
Navigator.maxTouchPoints을 사용해서 유저가 터치스크린을 가졌는지 여부를 감지한다. 그런 다음 if (!("maxTouchPoints" in Navigator)) { /*Code here*/}.한 경우에만 기본적으로(default) user agent 화면을 확인한다. 이 정보를 통해 어떤 디바이스가 터치스크린을 가졌는지 여부를 확인하고, 이를 사용해서 전체 레이아웃을 변경하지 않고 오직 터치 스크린일 경우에 대해서만 특정 작업을 더 만들거나 유지 관리를 할 수 있다. 예를 들어, 좀 더 크고, 클릭하기 쉬운 버튼과 같은 touch 편의성(convenience)을 추가한다. 이를 위해서 스크린 사이즈에 따라 단순하게 window.innerWidthwindow.addEventListener("resize", function(){ /_refresh screen size dependent things_/ }).를 사용하면 된다. 스크린 사이즈를 위해 해야하는 일은 작은 화면에서 보여줘야할 정보를 줄이는 것이 아니다. 이런 방법은 사람들에게 데스크톱 버전을 사용하도록 강제하는 것이기 때문에 짜증만 날 뿐이다. 이보다는 작은 스크린의 긴 페이지에서는 더 적은 열(columns)의 정보를 갖도록 하고, 더 큰 스크린의 짧은 페이지에서는 더 많은 열(cloumns)를 갖도록 한다. 이러한 효과는 CSS flexbox를 이용해서 쉽게 얻을 수 있다. 그리고 항상 코드를 동적으로 만들어야 한다. 유저는 모바일 디바이스를 옆으로 움직여서 페이지의 너비와 높이를 바꿀 수 있다. 그렇기 때문에 웹 페이지가 부드럽고 유동적이며 동적으로 크카가 조정되는 동안 개발자 도구를 열어서 화면 크기를 조정할 수 있을 때까지 웹 페이지에 만족하지 않도록 한다.


References
User agent
Browser detection using the user agent

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

자바스크립트의 타입

기본형 값들에 대한 일곱가지 타입 (string, number, boolean, null, undefined, symbol(ES2015에서 추가), bigint(최종 확정 단계))
객체

기본형은 불변이며, 메서드를 가지지 않는다. 그런데 기본형인 string이 메서드를 가진 것처럼 보이는 이유는 자바스크립트에 메서드를 가지는 String 객체 타입이 정의되어 있기 때문이다. 만약 charAt과 같은 메서드를 기본형에 사용한다면 기본형을 String 객체로 래핑하여 메서드를 호출한 후 마지막에 래핑한 객체를 버리는 방식으로 작동된다. null과 undefined를 제외한 모든 기본형에 이와 같은 객체 래퍼 타입이 존재한다. 이러한 래퍼 객체는 직접 생성할 필요가 없고, 기본형을 사용해야하는데 래퍼 객체를 사용하지 않도록 주의하여야 한다. 타입스크립트가 제공하는 타입 선언은 전부 기본형 타입이다. 다만 기본형 타입은 객체 래퍼에 할당할 수 있기 때문에 타입스크립트는 기본형 타입을 객체 래퍼에 할당하는 것을 허용한다. 그러나 이런 방법은 오해하기 쉽고 이렇게 쓰지 않는 것이 좋다.


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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//1️⃣ 잉여 속성 체크
interface Room {
numDoors: number;
ceilingHeightFt: number;
}
const r: Room = {
numDoors: 1,
ceilingHeightFt: 10,
elephant: 'present',
// ~~~~~~~~~~~~~~~~~~ Object literal may only specify known properties,
// and 'elephant' does not exist in type 'Room'
};

//2️⃣
interface Room {
numDoors: number;
ceilingHeightFt: number;
}
const obj = {
numDoors: 1,
ceilingHeightFt: 10,
elephant: 'present',
};
const r: Room = obj; // OK

1️⃣ 의 샘플코드는 잉여 속성 체크가 수행되었다. 구조적 타입 시스템에서 발생할 수 있는 중요한 오류를 잡을 수 있도록 한다. 1️⃣ 의 샘플코드는 구조적 타입의 관점에서 생각해보면 elephant 속성이 있어도 오류가 발생하지 않아야 하지만, 오류가 발생했다. 이처럼 잉여 속성 체크를 사용하면 타입 시스템의 구조적 본질을 해치치 않으면서도 객체 리터럴에 알 수 없는 속성을 허용하지 않음으로써 문제의 발생을 방지 할 수 있다. 단, 조건에 따라 동작하지 않을 수 있고, 통상적인 할당 가능 검사와 함께 쓰이면 구조적 타이핑이 무엇인지 혼란스러워 진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
interface Room {
numDoors: number;
ceilingHeightFt: number;
}
function setDarkMode() {}
interface Options {
title: string;
darkMode?: boolean;
}
const intermediate = { darkmode: true, title: 'Ski Free' };
const o: Options = intermediate; // OK

const o2 = { darkmode: true, title: 'Ski Free' } as Options; // OK

위 예시에서 intermediate 변수의 오른쪽은 객체 리터럴이지만 o변수의 intermediate는 객체 리터럴이 아니다. 타입 구문이 없는 임시 변수이다. 이 경우 잉여 속성 체크가 적용되지 않아서 오류가 사라진다. 위의 예시에서 o2와 같이 타입 단언을 사용해도 잉여 속성 체크는 적용되지 않는다. 아래 예시처럼 선택적 속성만 가지는 약한 타입에도 비슷한 체크가 동작한다.

1
2
3
4
5
6
7
8
9
interface LineChartOptions {
logscale?: boolean;
invertedYAxis?: boolean;
areaChart?: boolean;
}
const opts = { logScale: true };
const o: LineChartOptions = opts;
// ~ Type '{ logScale: boolean; }' has no properties in common
// with type 'LineChartOptions'

위 예시에서 LineChartOptions 타입은 모든 속성이 선택적이므로 모든 객체를 포함할 수 있는 약한 타입이다. 이 경우 타입스크립트가 값 타입과 선언 타입에 공통된 속성이 있는지 확인하는 별도의 체크를 수행한다. 오타를 잡는데 효과적이며 구조적으로 엄격하지 않지만 잉여 속성 체크와 다른 점은 약한 타입과 관련된 할당문마다 수행된다는 점이다. 따라서 임시 변수를 제거하더라도 공통 속성 체크는 여전히 동작한다.


TL; DR

  • 객체 리터럴을 변수에 할당하거나 함수에 매개변수로 전달할 때 잉여 체크 속성이 수행된다.
  • 잉여 속성 체크는 오류를 찾는 효과적인 방법이지만, 타입스크립트 타입 체커가 수행하는 일반적인 구조적 할당 가능성 체크와 역할이 다르다. 할당의 개념을 정확히 알아야 잉여 속성 체크와 일반적인 구조적 할당 가능성 체크를 구분할 수 있다.
  • 잉여 속성 체크에는 한계가 있다. 임시 변수를 도입하면 잉여 속성 체크를 건너뛸 수 있다.

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

자바스크립트에서는 함수 문장(statement)와 함수 표현식(expression)을 다르게 인식한다.

1
2
3
4
5
6
7
8
9
function rollDice1(sides: number): number {
/* COMPRESS */ return 0; /* END */
} // Statement
const rollDice2 = function (sides: number): number {
/* COMPRESS */ return 0; /* END */
}; // Expression
const rollDice3 = (sides: number): number => {
/* COMPRESS */ return 0; /* END */
}; // Also expression

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//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;
}

//2️⃣ 함수 시그니처를 하나의 함수 타입으로 통합 - 함수 표현식 전체 타입을 정의
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;

1️⃣ 에 비해 2️⃣ 가 코드가 간결하고 안전하다.


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

타입스크립트에서 명명된 타입을 정의하는 방법은 아래와 같이 type, interface의 두 가지 방법이 있다. 대부분의 경우 둘 중 어떤 것을 사용해도 상관없지만 두 가지 타입이 가지는 차이점을 명확히 알고 사용해야 한다.

1
2
3
4
5
6
7
8
type TState = {
name: string;
capital: string;
};
interface IState {
name: string;
capital: string;
}

type, interface의 비슷한 점

  • 명명된 타입은 type, interface 둘 중 어떤 것으로 정의하든 상태에 차이가 없지만, 추가 속성과 함께 할당하려고 하면 오류가 발생한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//예제코드에서 type은 T, interface는 I를 접두사로 사용했는데,
//이해를 돕기 위함이며, 실제코드에서는 이렇게 사용하지 않도록 한다.

type TState = {
name: string;
capital: string;
};
interface IState {
name: string;
capital: string;
}
const wyoming: TState = {
name: 'Wyoming',
capital: 'Cheyenne',
population: 500_000,
// ~~~~~~~~~~~~~~~~~~ Type ... is not assignable to type 'TState'
// Object literal may only specify known properties, and
// 'population' does not exist in type 'TState'
};
  • 인덱스 시그니처의 사용이 가능하다.
1
2
3
4
type TDict = { [key: string]: string };
interface IDict {
[key: string]: string;
}
  • 함수 타입도 정의할 수 있고, 제너릭이 가능하다.
1
2
3
4
5
6
7
8
type TFn = (x: number) => string;
interface IFn {
(x: number): string;
}

//단순한 함수 타입에서는 타입 별칭이 더 나은 선택일 수 있다.
const toStrT: TFn = (x) => '' + x; // OK
const toStrI: IFn = (x) => '' + x; // OK
  • 타입을 확장할 수 있다. 단 interface는 주의사항이 몇 가지 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type TState = {
name: string;
capital: string;
};
interface IState {
name: string;
capital: string;
}

interface IStateWithPop extends TState {
population: number;
}

// interface는 유니온 타입과 같은 복잡한 타입을 확장하지 못한다.
// 확장하려면 아래와 같이 타입과 &을 사용해야 한다.
type TStateWithPop = IState & { population: number };
  • class를 사용하면 type, interface 모두 사용 가능.

type, interface의 다른 점

  • interface는 타입을 확장할 수 있고, 유니온은 할 수 없다. 유니온 타입은 있지만 유니온 인터페이스라는 개념은 없다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Input = {
/* ... */
};
type Output = {
/* ... */
};

// Input 타입과 Output 타입은 별도의 타입인데, VariableMap interface를 만들 수 있다.
interface VariableMap {
[name: string]: Input | Output;
}

//아래와 같이 유니온 타입에 name 속성을 붙인 타입을 만들 수도 있다.
//이 타입은 interface로 표현할 수 없다.
type NamedVariable = (Input | Output) & { name: string };

type 키워드는 interface보다 쓰임새가 많은데, 유니온이 될 수도 있고, 매핑된 타입 또는 조건부 타입 같은 고급 기능에도 활용된다. 튜플과 배열 타입을 표현하는 것도 용이하다. interface로 튜플과 비슷하게 구현할 수 있지만 concat과 같은 메서드를 사용할 수 없다.

  • interface는 보강이 가능하고, type은 그렇지 않다.
1
2
3
4
5
6
7
8
9
10
11
12
interface IState {
name: string;
capital: string;
}
interface IState {
population: number;
}
const wyoming: IState = {
name: 'Wyoming',
capital: 'Cheyenne',
population: 500_000,
}; // OK

위 예제에서 속싱이 확장되었는데 이를 선언 병합(declaration merging)라고 한다. 선언 병합은 주로 타입 선언 파일에 사용횐다. 즉 타입 선언 파일을 작성할 때는 선언 병합을 지원하기 위해 반드시 interface를 사용해야 한다.

type, interface를 언제 써야하는가?

  • 타입 선언 파일 뿐 아니라 일반적인 코드에서도 병합이 지원된다. 따라서 보강이 있는 경우는 interface, 기존 타입에 추가 보강이 없는 경우는 type을 쓴다.
  • 복잡한 타입은 타입 별칭을 사용한다.
  • type, interface 두 가지 모두로 표현할 수 있는 간단한 객체 타입이라면?
    • 일관성과 보강의 관점을 고려해 본다.
    • 코드베이스에서 일관되게 type을 쓰고 있다면 type을, interface를 쓰고 있다면 interface를 쓴다.
    • API에 대한 타입 선언은 API가 변경될 때 사용자가 interface를 통해 새로운 필드를 병합할 수 있으니 interface를 쓴다. 단, 프로젝트 내부적으로 사용되는 타입에 선언 병합이 발생하는 것은 잘못된 설계이며, 이럴 때는 type을 쓴다.

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

코드를 작성할 때 코드를 반복하지 말라는 DRY(don’t repeat yourself)원칙에 따라 코드 중복을 제거하려고 노력하는 사람도 타입에 대해 간과하기 쉽다. 그 이유는 중복을 제거하는 매커니즘이 기존 코드에 대해 행하던 것에 비해 익숙치 않기 때문이다. 그러나 타입의 중복도 많은 문제를 일으키기 때문에 중복을 최소화해야 한다.

반복을 줄이는 방법

  • 타입에 이름을 붙인다. 아래 예시에서 파라미터 a, b에 반복되는 타입인 { x: number; y: number }은 Point2D interface로 이름을 붙여서 중복을 제거하였다. 몇몇 함수가 같은 타입 시그니처를 공유한다고 할 때도 해당 시그니처를 명명된 타입으로 본리할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
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) {
/* ... */
}
  • interface를 사용할 경우, 한 interface가 다른 interface를 확장하게 해서 반복을 제거한다.
1
2
3
4
5
6
7
8
interface Person {
firstName: string;
lastName: string;
}

interface PersonWithBirthDate extends Person {
birth: Date;
}
  • 이미 존재하는 타입을 확장한다면 intersection 연산자(&)을 쓴다. 단, 일반적이지는 않다. 주로 확장할 수 없는 유니온 타입에 속성을 추가하려고 할 때 유용한 방법이다.
1
2
3
4
5
interface Person {
firstName: string;
lastName: string;
}
type PersonWithBirthDate = Person & { birth: Date };
  • 매핑된 타입을 사용한다.
    아래 예시에서 State는 전체 어플리케이션의 상태, TopNavState는 부분만 표현하는 상태라고 하고, 어떻게 매핑된 타입을 사용하는지 살펴보자. TopNavState를 확장해서 State를 구성할 수도 있지만, 의미상 State의 TopNavState를 정의하는 것이 바람직 할 것이다.
1
2
3
4
5
6
7
8
9
10
11
interface State {
userId: string;
pageTitle: string;
recentFiles: string[];
pageContents: string;
}
interface TopNavState {
userId: string;
pageTitle: string;
recentFiles: string[];
}

아래 예시에서 1️⃣은 의미상 TopNavState를 State의 부분 집합이 되도록 작성하기 위해 State를 인덱싱해서 속성의 타입에 중복을 제거하였다. 이렇게 하면 State에 있는 속성의 타입이 바뀌더라도 잘 반영된다. 하지만 여전히 반복되는 코드가 있기 때문에 2️⃣와 같이 매핑된 타입을 사용한다. 매핑된 타입은 배열의 필드를 루프 도는 것과 같은 방식이다. 표준 라이브러리에서는 Pick이라 한다. Pick은 제너릭 타입이며, 3️⃣과 같이 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//1️⃣ - 인덱싱하여 중복제거
type TopNavState = {
userId: State['userId'];
pageTitle: State['pageTitle'];
recentFiles: State['recentFiles'];
};

//2️⃣ - 매핑된 타입 사용
type TopNavState = {
[k in 'userId' | 'pageTitle' | 'recentFiles']: State[k];
};

//3️⃣ - Pick 사용
type Pick<T, K extends keyof T> = { [k in K]: T[k] }; // Pick의 정의
//만약 Pick에 잘못된 키를 넣으면 오류가 발생한다.
type TopNavState = Pick<State, 'userId' | 'pageTitle' | 'recentFiles'>;

태그된 유니온에서 중복이 발생하면 어떻게 할 수 있을까? 아래 예시에서 ‘save’,’load’가 중복된다.

1
2
3
4
5
6
7
8
9
interface SaveAction {
type: 'save';
// ...
}
interface LoadAction {
type: 'load';
// ...
}
type Action = SaveAction | LoadAction;

위와 같이 중복이 발생할 때는 Action 유니온을 인덱싱하여 ActionType을 정의한다. 이제 Action에 타입이 더 추가되더라도 ActionType은 자동으로 그 타입을 포함하게 된다.

1
2
3
4
type ActionType = Action['type']; // Type is "save" | "load"

//Pick을 사용하여 type 속성을 가지는 interface와 인덱싱을 사용하는 방법은 다르다.
type ActionRec = Pick<Action, 'type'>; // {type: "save" | "load"}
  • 매핑된 타입과 keyof를 사용한다. 아래와 같이 생성한 후 업데이트가 되는 클래스를 정의할 때를 가정해본다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
interface Options {
width: number;
height: number;
color: string;
label: string;
}
interface OptionsUpdate {
width?: number;
height?: number;
color?: string;
label?: string;
}
class UIWidget {
constructor(init: Options) {
/* ... */
}
update(options: OptionsUpdate) {
/* ... */
}
}

이때 매핑된 타입과 keyof를 사용하여 OptionsUpdate를 만든다.

  • keyof는 타입을 받아서 속성 타입의 유니온을 반환한다.
  • 매핑된 타입[k in keyof Options]은 순회하며 Options 내 k 값에 해당하는 속성이 있는지 찾는다.
  • ?는 속성을 선택적으로 만드는데 표준 라이브러리에는 Partial이라는 이름으로 포함되어 있다.
1
2
3
4
type OptionsUpdate = { [k in keyof Options]?: Options[k] };

type OptionsKeys = keyof Options;
// Type is "width" | "height" | "color" | "label"
  • typeof를 사용한다. 값의 형태에 해당하는 타입을 정의하고 싶을 때 사용하는 방법이다. 자바스트립트의 typeof처럼 보이지만 실제로는 타입스크립트 단계에서 연산된다. 단, 값으로부터 타입을 만들어 낼 때 선언의 순서에 주의한다. 타입 정의 후 값이 그 타입에 할당 가능하다고 선언하는 것이 명확하고, 예상하기 어려운 타입 변동을 방지할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const INIT_OPTIONS = {
width: 640,
height: 480,
color: '#00FF00',
label: 'VGA',
};
interface Options {
width: number;
height: number;
color: string;
label: string;
}

type Options = typeof INIT_OPTIONS;
  • 함수나 메서드의 반환 값에 명명된 타입을 만들고 싶다면 ReturnType을 사용한다. 아래 예시에서 ReturnType은 함수의 인 getUserInfo가 아닌 typeof getUserInfo에 적용되었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const INIT_OPTIONS = {
width: 640,
height: 480,
color: '#00FF00',
label: 'VGA',
};
function getUserInfo(userId: string) {
// COMPRESS
const name = 'Bob';
const age = 12;
const height = 48;
const weight = 70;
const favoriteColor = 'blue';
// END
return {
userId,
name,
age,
height,
weight,
favoriteColor,
};
}
// Return type inferred as { userId: string; name: string; age: number, ... }

type UserInfo = ReturnType<typeof getUserInfo>;
  • 제너릭 타입을 사용한다. 제너릭 타입은 타입을 위한 함수와 같다. 다만 함수에서 매개변수로 매핑할 수 있는 값을 제한하기 위해 타입 시스템을 사용하는 것처럼 매개변수를 제한할 수 있는 방법이 필요하다. 제너릭 타입에서 그 방법은 extends를 사용하는 것이다. 이는 제너릭 매개변수가 특정 타입을 확장한다고 선언할 수 있게 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
interface Name {
first: string;
last: string;
}
type DancingDuo<T extends Name> = [T, T];

const couple1: DancingDuo<Name> = [
{ first: 'Fred', last: 'Astaire' },
{ first: 'Ginger', last: 'Rogers' },
]; // OK

//{first:string}은 Name을 확장하지 않기 때문에 오류 발생
const couple2: DancingDuo<{ first: string }> = [
// ~~~~~~~~~~~~~~~
// Property 'last' is missing in type
// '{ first: string; }' but required in type 'Name'
{ first: 'Sonny' },
{ first: 'Cher' },
];

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//1️⃣ 인덱스 시그니처
type Rocket = { [property: string]: string };
const rocket: Rocket = {
name: 'Falcon 9',
variant: 'v1.0',
thrust: '4,940 kN',
}; // OK

//2️⃣ 인터페이스
interface Rocket {
name: string;
variant: string;
thrust_kN: number;
}
const falconHeavy: Rocket = {
name: 'Falcon Heavy',
variant: 'v1',
thrust_kN: 15_200,
};

위 예제 1️⃣에서 [property: string]: string이 인덱스 시그니처이다.

  • 키의 이름: 키의 위치만 표시. 타입 체커에서는 사용하지 않는다.
  • 키의 타입: string이나 number 또는 symbol의 조합이어야 하지만 보통 string을 사용한다.
  • 값의 타입: 어떤 것이든 가능하다.

그러나 인덱스 시그니처는 타입 체크 수행 시 아래와 같은 단점을 가진다.

  • 잘못된 키를 포함한 모든 키를 허용 (name이 아닌 Name도 유효한 Rocket의 타입)
  • 특정 키가 필요하지 않아. ({}도 유효한 Rocket의 타입)
  • 키마다 다른 타입을 가질 수 없다. (thrust만 number일 수도 있는데 그렇게 사용할 수 없다.)
  • 키는 무엇이든 가능하기 때문에 언어서비스(자동 완성 기능)이 제대로 동작하지 않는다.

이런 부정확한 부분을 개선하기 위해 2️⃣와 같이 인터페이스로 작성한다.

그렇다면 인덱스 시그니처는 어떤 상황에서 사용해야 할까?

동적 데이터를 표현할 때, 런타임 때가지 객체의 속성을 알 수 없을 경우

예를 들어 CSV 파일처럼 행, 열에 이름이 있고 데이터 행을 열 이름과 값으로 매핑하는 객체로 나타내고 싶다면 인덱스 시그니처를 사용할 수 있다.

  • 열 이름이 무엇인지 모른다 -> 인덱스 시그니처 사용
  • 열 이름을 알고 있다 -> 미리 선언해 둔 타입 사용. 단, 런타임에 실제로 일치하지 않을 수도 있으므로 undefined를 추가해서 나타낼 수 있다.
  • 어떤 타입에 가능한 필드가 제한되어 있다(e.g. 데이터 상에 키를 알고 있는데, 그것이 얼마나 있는지 알 수 없다.) -> 선택적 필드 또는 유니온 타입을 사용

string 타입이 너무 광범위해서 인덱스 시그니처를 사용하는 데 문제가 있다면?

  • Record를 사용한다. 키 타입에 유연성을 제공하는 제너릭 타입으로 string의 부분 집합을 사용할 수 있다.
  • 매핑된 타입을 사용한다. 키마다 별도의 타입을 사용하게 해준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//Record 사용
type Vec3D = Record<'x' | 'y' | 'z', number>;
// Type Vec3D = {
// x: number;
// y: number;
// z: number;
// }

//매핑된 타입 사용
type Vec3D = { [k in 'x' | 'y' | 'z']: number };
// Same as above
type ABC = { [k in 'a' | 'b' | 'c']: k extends 'b' ? string : number };
// Type ABC = {
// a: number;
// b: string;
// c: number;
// }

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

자바스크립트의 객체는 키/값 쌍의 모음인데, 키는 보통 문자열, 값은 어떤 것이든 될 수 있다. 그래서 숫자는 키로 사용할 수 없다. 배열의 경우, 분명히 객체인데 숫자 인덱스를 사용하는 것이 당연하다.

1
2
3
4
5
6
x = [1, 2, 3];
x[0]; //1

x['1']; //2

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

배열에서 인덱스는 숫자타입이더라도 문자열로 변환되어 사용한다. 따라서 문자열 키를 사용해도 배열의 요소에 접근할 수 있다. Object.keys를 이용하여 배열의 키를 나열해보면 문자열로 구성되어 있음을 알 수 있다. 타입스크립트는 이러한 혼란을 바로 잡기 위해서 숫자 키를 허용하고, 문자열 키와 다른 것으로 인식한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//for ~ in - 배열을 순회하는 방법이지만 좋은 방법은 아니다.
const xs = [1, 2, 3];
const keys = Object.keys(xs); // Type is string[]
for (const key in xs) {
key; // Type is string
const x = xs[key]; // Type is number
}

//for ~ of - 인덱스에 신경쓰지 않는 경우
for (const x of xs) {
x; // Type is number
}

//Array.prototype.forEach - 인덱스의 타입이 중요한 경우
xs.forEach((x, i) => {
i; // Type is number
x; // Type is number
});

//for - 루프 중간에 멈춰야 하는 경우
for (let i = 0; i < xs.length; i++) {
const x = xs[i];
if (x < 0) break;
}

타입이 불확실하면, for ~ in 루프는 for ~ of 루프에 비해 몇 배 느리다. 여기서 for ~ in, for ~ of는 둘 다 반복문인데, for ~ in은 반복가능한 객체를 순환하고, for ~ of는 배열 요소를 탐색한다. 두 가지가 다른점은 for ~ in의 대상이 되는 객체는 이터러블이 아니므로 객체에 대해서 for ~ of를 사용하면 에러가 발생한다는 것이고, for ~ in의 대상이 되는 배열의 경우 이터러블한 객체여서 for~of를 적용해도 에러가 발생하지 않는다. 다만 배열에 대해 for ~ in을 사용하면 객체의 키 값에 해당하는 인덱스가 나오고, for ~ of를 쓰면 해당요소가 나온다는 점이 다르다.

배열은 인덱스 시그니처가 number로 표현되어 있다면 입력한 값이 number여야 한다는 것을 의미하지만 실제 런타임에 사용되는 키는 string타입이다. 그러나 일반적으로 string 대신 number를 타입의 인덱스 시그니처로 사용할 이유는 많지 않다. 만약 숫자를 사용하여 인덱스할 항목을 지정한다면 Array 또는 튜플 타입을 사용한다. number를 인덱스로 쓰면 어떤 특별한 의미를 지닌다는 오해를 불러 일으킬 수 있다. 그리고, 어떤 길이를 가지는 배열과 비슷한 형태의 튜플을 사용하고 싶다면 타입스크립트에 있는 ArrayLike 타입을 사용한다. 단, ArrayLike를 사용해도 키는 여전히 문자열이다.

1
2
3
4
5
6
7
const xs = [1, 2, 3];
function checkedAccess<T>(xs: ArrayLike<T>, i: number): T {
if (i < xs.length) {
return xs[i];
}
throw new Error(`Attempt to access ${i} which is past end of array.`);
}

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

아래 예제에서 1️⃣은 계산이 끝나면 원래 배열이 전부 비게 되는데도, 자바스크립트는 배열의 내용을 변경할 수 있기 때문에 타입스크립트에서 오류 없이 통과한다. 그래서 오류의 범위는 좁히기 위해 readonly 접근 제어자를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 1️⃣
function arraySum(arr: number[]) {
let sum = 0,
num;
while ((num = arr.pop()) !== undefined) {
sum += num;
}
return sum;
}

// 2️⃣
function arraySum(arr: readonly number[]) {
let sum = 0,
num;
while ((num = arr.pop()) !== undefined) {
// ~~~ 'pop' does not exist on type 'readonly number[]'
sum += num;
}
return sum;
}

위 예제에서 2️⃣의 readonly number[]는 타입이다. 특징은 아래와 같다.

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

매개변수를 readonly로 선언하면 특징은 다음과 같다.

  • 타입스크립트는 매개변수가 함수 내에서 변경이 일어나는지 체크한다.
  • 호출하는 쪽에서 함수가 매개변수를 변경하지 않는다는 보장을 받게 된다.
  • 호출하는 쪽에서 함수에 readonly 배열을 매개변수에 넣을 수 있다.
  • 함수가 매개변수를 변경하지 않는다면, readonly로 선언한다.
  • readonly는 얕게(shallow) 동작하므로 만약 객체의 readonly 배열이 있다면 객체 자체는 readonly가 아니다.

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

다음은 타입 체커가 동작하도록 개선한 코드인데, 핵심은 매핑된 타입과 객체를 사용하는 것이다. [k in keyof ScatterProps]는 타입 체커에게 REQUIRES_UPDATE가 ScatterProps와 동일한 속성을 가져야 한다는 정보를 제공한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
interface ScatterProps {
// The data
xs: number[];
ys: number[];

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

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

References
[이펙티브 타입스크립트] 댄 밴더캄 지음

RTK Query

원문링크: https://redux-toolkit.js.org/rtk-query/api/createApi

createApi는 RTK Query의 핵심이다. 이를 통해 해당하는 데이터를 가져오고 변환하는 방법을 구성하는 것을 포함하여 일련의 엔드포인트들로부터 데이터를 어떻게 회수(검색)해야하는 지에 대해 일련의 엔드포인트에 대한 방법을 정의할 수 있다. createApi는 여기에 캡슐화된 데이터 fetching와 caching 프로세스는 포함하는 Redux 로직(옵셔널한 React hooks)이 담겨있는 API slice structure를 생성한다.

💡 일반적으로 어플리케이션이 통신해야 하는 base URL 하나 당 하나의 API slice만 있어야 한다. 예를 들어 당신의 사이트가 /api/posts와 /api/users 모두에서 데이터를 fetch한다면 /api/를 base URL로 가지는 single API slice를 가지고 있고, posts와 users로 엔드포인트 정의를 분할했을 것이다. 이렇게 하면 엔드포인트에서 태그 관계를 정의해서 자동화된 re-fetching의 이점을 효과적으로 사용할 수 있다.

유지(maintainability)의 목적을 위해 모든 엔드포인트를 포한하는 single API slice를 유지하면서 여러 파일에 걸쳐 endpoint 정의를 분할해서 사용할 수 있다. injectEndpoints 속성을 사용하여 다른 파일의 엔드포인트를 code splitting을 하는 방법은 code splitting을 참조한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//example: src/services/pokemon.ts

// React hook의 생성을 허용하려면 React의 특정 엔트리포인트를 사용해야 한다.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { Pokemon } from './types'

// baseURL과 엔드포인트를 사용하여 서비스를 정의한다.
export const pokemonApi = createApi({
reducerPath: 'pokemonApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
endpoints: (builder) => ({
getPokemonByName: builder.query<Pokemon, string>({
query: (name) => `pokemon/${name}`,
}),
}),
})

// 함수 컴포넌트에서 사용하기 위해 hooks를 export하고,
// 엔드포인트가 정의된 곳에 기반하여 자동 생성되도록 한다.
export const { useGetPokemonByNameQuery } = pokemonApi

Parameters

createApi는 아래와 같은 옵션이 있는 단을 구성 객체(single configuration object)를 받아들인다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
baseQuery(args: InternalQueryArgs, api: BaseQueryApi, extraOptions?: DefinitionExtraOptions): any;
endpoints(build: EndpointBuilder<InternalQueryArgs, TagTypes>): Definitions;
extractRehydrationInfo?: (
action: AnyAction,
{
reducerPath,
}: {
reducerPath: ReducerPath
}
) =>
| undefined
| CombinedState<Definitions, TagTypes, ReducerPath>
tagTypes?: readonly TagTypes[];
reducerPath?: ReducerPath;
serializeQueryArgs?: SerializeQueryArgs<InternalQueryArgs>;
keepUnusedDataFor?: number; // value is in seconds
refetchOnMountOrArgChange?: boolean | number; // value is in seconds
refetchOnFocus?: boolean;
refetchOnReconnect?: boolean;

baseQuery

base query는 queryFn 옵션이 특정되지 않은 경우에 각 endpoint에서 사용된다. RTK Query는 fetchBaseQuery라고 불리는 유틸리티로 일반적인 사용 케이스에서 fetch를 감싸는 경량 wrapper로써 export된다. 만약 fetchBaseQuery가 요구 사항을 처리할 수 없는 경우에 Customizing Queries를 참조하도록 한다.

baseQuery function arguments

  • args: 주어진 endpoint에 대한 query 함수의 반환값
  • api - signal, dispatch, getState, extra 속성을 포함하는 BaseQueryApi 객체
    • singal: Dom 요청을 중단하거나 요청이 중단되었는지 여부를 읽어들이는 데 사용할 수 있는 AbortSignal 객체
    • dispatch: Redux store와 대응하게 하는 store.dispatch 매서드
    • getState: 현재의 store 상태에 접근하기 위해 호출될 수 있는 함수
    • extra: configureStore getDefaultMiddleware option에 thunk.extraArgument로 제공된다.
  • extraOptions: 주어진 endpoint를 위해 제공되는 선택적 extraOptions의 값
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Base Query signature
export type BaseQueryFn<
Args = any,
Result = unknown,
Error = unknown,
DefinitionExtraOptions = {},
Meta = {}
> = (
args: Args,
api: BaseQueryApi,
extraOptions: DefinitionExtraOptions
) => MaybePromise<QueryReturnValue<Result, Error, Meta>>

export interface BaseQueryApi {
signal: AbortSignal
dispatch: ThunkDispatch<any, any, any>
getState: () => unknown
}

export type QueryReturnValue<T = unknown, E = unknown, M = unknown> =
| {
error: E
data?: undefined
meta?: M
}
| {
error?: undefined
data: T
meta?: M
}

endpoints

1
2
3
4
5
6
7
8
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'

const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/' }),
endpoints: build => ({
// ...endpoints
})
})

endpoints는 서버에 대해 수행하려는 작업들의 집합이다. builder syntax를 사용해서 객체로 정의한다. 여기에는 query, mutaion 의 두 가지 기본적인 엔드포인트 유형이 있다.

각 속성에 대한 세부내용이 궁금하여 다음의 링크를 참조한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// Query endpoint definition

export type QueryDefinition<
QueryArg,
BaseQuery extends BaseQueryFn,
TagTypes extends string,
ResultType,
ReducerPath extends string = string
> = {
query(arg: QueryArg): BaseQueryArg<BaseQuery>

/* either `query` or `queryFn` can be present, but not both simultaneously */
queryFn(
arg: QueryArg,
api: BaseQueryApi,
extraOptions: BaseQueryExtraOptions<BaseQuery>,
baseQuery: (arg: Parameters<BaseQuery>[0]) => ReturnType<BaseQuery>
): MaybePromise<QueryReturnValue<ResultType, BaseQueryError<BaseQuery>>>

/* transformResponse only available with `query`, not `queryFn` */
transformResponse?(
baseQueryReturnValue: BaseQueryResult<BaseQuery>,
meta: BaseQueryMeta<BaseQuery>,
arg: QueryArg
): ResultType | Promise<ResultType>

extraOptions?: BaseQueryExtraOptions<BaseQuery>

providesTags?: ResultDescription<
TagTypes,
ResultType,
QueryArg,
BaseQueryError<BaseQuery>
>

keepUnusedDataFor?: number

onQueryStarted?(
arg: QueryArg,
{
dispatch,
getState,
extra,
requestId,
queryFulfilled,
getCacheEntry,
updateCachedData, // available for query endpoints only
}: QueryLifecycleApi
): Promise<void>

onCacheEntryAdded?(
arg: QueryArg,
{
dispatch,
getState,
extra,
requestId,
cacheEntryRemoved,
cacheDataLoaded,
getCacheEntry,
updateCachedData, // available for query endpoints only
}: QueryCacheLifecycleApi
): Promise<void>
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// Mutation endpoint definition

export type MutationDefinition<
QueryArg,
BaseQuery extends BaseQueryFn,
TagTypes extends string,
ResultType,
ReducerPath extends string = string,
Context = Record<string, any>
> = {
query(arg: QueryArg): BaseQueryArg<BaseQuery>

/* either `query` or `queryFn` can be present, but not both simultaneously */
queryFn(
arg: QueryArg,
api: BaseQueryApi,
extraOptions: BaseQueryExtraOptions<BaseQuery>,
baseQuery: (arg: Parameters<BaseQuery>[0]) => ReturnType<BaseQuery>
): MaybePromise<QueryReturnValue<ResultType, BaseQueryError<BaseQuery>>>

/* transformResponse only available with `query`, not `queryFn` */
transformResponse?(
baseQueryReturnValue: BaseQueryResult<BaseQuery>,
meta: BaseQueryMeta<BaseQuery>,
arg: QueryArg
): ResultType | Promise<ResultType>

extraOptions?: BaseQueryExtraOptions<BaseQuery>

invalidatesTags?: ResultDescription<TagTypes, ResultType, QueryArg>

onQueryStarted?(
arg: QueryArg,
{
dispatch,
getState,
extra,
requestId,
queryFulfilled,
getCacheEntry,
}: MutationLifecycleApi
): Promise<void>

onCacheEntryAdded?(
arg: QueryArg,
{
dispatch,
getState,
extra,
requestId,
cacheEntryRemoved,
cacheDataLoaded,
getCacheEntry,
}: MutationCacheLifecycleApi
): Promise<void>
}

endpoints에 익숙해지는 방법

아래와 같이 getPosts를 키로 정의할 때, 이 이름이 api로부터 export되고, api.endpoints.getPosts.useQuery(), api.endpoints.getPosts.initiate(), api.endpoints.getPosts.select() 아래에서 reference될 수 있다는 것을 아는 것이 중요하다. 비슷한 방식으로 mutaion에도 적용이 되는데 useQuery대신 useMutaion이 적용된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
interface Post {
id: number
name: string
}
type PostsResponse = Post[]

const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/' }),
tagTypes: ['Posts'],
endpoints: (build) => ({
getPosts: build.query<PostsResponse, void>({
query: () => 'posts',
providesTags: (result) =>
result ? result.map(({ id }) => ({ type: 'Posts', id })) : [],
}),
addPost: build.mutation<Post, Partial<Post>>({
query: (body) => ({
url: `posts`,
method: 'POST',
body,
}),
invalidatesTags: ['Posts'],
}),
}),
})

// Auto-generated hooks
export const { useGetPostsQuery, useAddPostMutation } = api

// Possible exports
export const { endpoints, reducerPath, reducer, middleware } = api
// reducerPath, reducer, middleware are only used in store configuration
// endpoints will have:
// endpoints.getPosts.initiate(), endpoints.getPosts.select(), endpoints.getPosts.useQuery()
// endpoints.addPost.initiate(), endpoints.addPost.select(), endpoints.addPost.useMutation()
// see `createApi` overview for _all exports_

extractRehydrationInfo

모든 디스패치된 액션에 전달되는 함수이다. 이것이 undefined 이외의 값을 반환하면 해당 반환 값은 성공하였거나 실패한 커리를 rehydrate하는데 사용된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//next-redux-wrapper rehydration example

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { HYDRATE } from 'next-redux-wrapper'

export const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/' }),
extractRehydrationInfo(action, { reducerPath }) {
if (action.type === HYDRATE) {
return action.payload[reducerPath]
}
},
endpoints: build => ({
// omitted
})
})

See also Server Side Rendering and Persistence and Rehydration.

tagTypes

string tag type 이름들이 있는 배열이다. tag types를 지정하는 것은 선택적이나 caching과 invalidation에 사용하고 싶다면 정의해야 한다. tag type을 정의할 때, providesTags 와 함께 제공하고, endpoints를 구성할 때 invalidatesTags 와 함께 무효화시킬 수 있다.

1
2
3
4
5
6
7
8
9
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'

const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/' }),
tagTypes: ['Post', 'User'],
endpoints: build => ({
// ...endpoints
})
})

reducerPath

서비스가 당신의 store에 장착될 수 있도록 하는 unique key이다. 당신의 어플리케이션에서 createApi를 한 번 이상 호출했다면 당신은 매번 unique한 value를 제공해야 하고, default는 api이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'

const apiOne = createApi({
reducerPath: 'apiOne',
baseQuery: fetchBaseQuery({ baseUrl: '/' }),
endpoints: builder => ({
// ...endpoints
})
})

const apiTwo = createApi({
reducerPath: 'apiTwo',
baseQuery: fetchBaseQuery({ baseUrl: '/' }),
endpoints: builder => ({
// ...endpoints
})
})

References
createApi

substr

💡 substr은 새로운 웹사이트에서는 더이상 사용되지 않는다. 웹 표준에서 제거된 것은 아니지만 다른 매서드로 대체하는 것이 바람직하다.

문자열에서 특정 위치에서 시작하여 특정 문자 수 만큼의 문자들을 반환한다.

1
2
3
4
5
6
7
const str = 'hello'

console.log(str.substr(1, 2))
// expected output: "el"

console.log(str.substr(2))
// expected output: "llo"

str.substr(start[, length])
str.substr(시작위치의 인덱스, 추출한 문자의 length )

  • start 값이 양수이고, 문자열 전체 길이보다 크거나 같으면 빈 문자열을 반환한다.
  • start가 음수이면 문자열 끝에서 start 숫자만큼 뺀 곳에서 시작한다.
  • start가 음수이고, 절대값이 문자열 전체보다 크면 문자열의 0번째 인덱스부터 시작
  • length가 0 이거나 음수이면 빈 문자열 반환한다.
  • length가 생략되었다면 문자열의 끝까지 추출하여 반환한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const str = 'helloworld'

str.substr(1, 2)
// 1번째 index에서 2개 추출
// expected output:'el'
str.substr(-3, 2)
// 전체 문자열 길이 10에서 -3만큼 뺀 곳에서 시작하여 2개 추출
// expected output:'rl'
str.substr(-3)
// start가 음수이므로 전체 문자열 길리 10에서 -3만큼 뺀 곳에서 시작,
// length는 생략되었으므로 문자열의 끝까지 추출
// expected output:'rld'
str.substr(1)
// 1번째 인덱스에서 시작하여 length는 생략되었으므로 문자열의 끝까지 추출
// expected output:'elloworld'
str.substr(-20, 2)
// start가 음수이고, 절대값이 문자열 전체보다 크면 문자열의 0번째 인덱스부터 시작하여 2개 추출
// expected output:'he'
str.substr(20, 2)
// start가 양수이나 문자열 전체 길이보다 크기 때문에 빈 문자열 반환
// expected output:''

substring

시작 인덱스로 부터 종료 인덱스 전 까지 문자열의 부분 문자열을 반환

1
2
3
4
5
6
7
const str = 'hello'

console.log(str.substr(1, 3))
// expected output: "el"

console.log(str.substr(2))
// expected output: "llo"

str.substring(indexStart[, indexEnd])
str.substring(시작 인덱스, 마지막 인덱스(포함하지 않음))
return 값: 기존문자열의 부분 문자열

  • 시작 인덱스부터 문자를 추출하여 마지막 인덱스가 포함되지 않아도 된다.
  • indexEnd가 생략되었다면 문자열 끝까지 모든 문자를 추출한다.
  • indexStart, indexEnd가 같을 경우 빈 문자열을 반환한다.
  • indexStart가 indexEnd보다 클 경우 두 개의 인자를 바꾼 것처럼 작동한다.
  • 0보다 작은 인자 값을 가지면 0, stringName.length보다 큰 인자값을 가지면 stringName.length로 처리된다.
  • NaN 값은 0으로 처리된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const anyString = 'helloworld'

// Displays 'h'
console.log(anyString.substring(0, 1))
//indexStart가 indexEnd보다 클 경우 substring(0, 1)처럼 두 개의 인자를 바꾼듯이 작동한다.
console.log(anyString.substring(1, 0))

// Displays 'hello'
console.log(anyString.substring(0, 6))

//Displays 'world'
console.log(anyString.substring(5))

//Displays 'wo'
console.log(anyString.substring(4, 7))
//indexStart가 indexEnd보다 클 경우 substring(4, 7)처럼 두 개의 인자를 바꾼듯이 작동한다.
console.log(anyString.substring(7, 4))

// Displays 'helloworld'
console.log(anyString.substring(0, 10))
//stringName.length보다 큰 인자값을 가지면 stringName.length로 처리된다.
console.log(anyString.substring(0, 15))

substring()와 length 속성을 같이 사용하기

1
2
3
4
5
6
7
8
9
// Displays 'orld' the last 4 characters
const anyString = 'helloworld'
const anyString4 = anyString.substring(anyString.length - 4)
console.log(anyString4)

// Displays 'world' the last 5 characters
const anyString = 'helloworld'
const anyString5 = anyString.substring(anyString.length - 5)
console.log(anyString5)

substring 메서드와 length 송성을 사용하여 특정 문자열의 마지막 문자를 추출할 수 있다. 이 방법을 사용하면 anyString.substring(4, 7)과 같이 시작인덱스와 마지막인덱스를 알지 않아도 된다.

substring()와 substr()의 차이점

substring()와 substr()에는 미묘한 차이가 있으므로 주의해야 한다. substring()은 시작과 마지막 인덱스를 의미하는데, substr()은 시작인덱스와 반환된 문자열에 포함할 문자 수를 나타낸다.

  • str.substring(indexStart[, indexEnd])
  • str.substr(start[, length])
1
2
3
4
5
const text = 'helloworld'
//2번째 인덱스에서 시작하여 5번째 인덱스 전까지 추출
console.log(text.substring(2, 5)) // => "llo"
//2번째 인덱스에서 시작하여 3개까지 추출
console.log(text.substr(2, 3)) // => "llo"

substring()와 slice()의 차이점

substring()와 slice()는 거의 똑같은데, 미묘하게 다른 점이 존재한다. 특히 부정적인 인수를 처리하는 방식에 차이가 있다.

  • substring(): indexStart가 indexEnd보다 클 경우 두 가지 인수를 바꾼듯이 작동한다.
  • slice(): indexStart가 indexEnd보다 클 경우 빈 문자열을 반환한다.
1
2
3
4
5
const text = 'helloworld'
//indexStart가 indexEnd보다 크기 때문에 text.substring(2,5)처럼 작동한다.
console.log(text.substring(5, 2)) // => "llo"
//indexStart가 indexEnd보다 크기 때문에 빈문자열을 반환한다.
console.log(text.slice(5, 2)) // => ""

두 가지 인수 모두 음수거나 NaN인 경우, substring()

  • substring(): 두 가지 인수 모두 음수거나 NaN인 경우 인수가 0인 것처럼 처리한다.
1
2
3
4
//substring(0, 2)인 것처럼 처리
console.log(text.substring(-5, 2)) // => "he"
//substring(0)인 것처럼 처리
console.log(text.substring(-5, -2)) // => ""
  • slice(): 두 가지 인수 모두 음수거나 NaN인 경우 NaN인수는 0처럼 취급하지만, 음수일 때는 문자열 끝에서 역방향으로 계산한다.
1
2
3
4
5
6
7
//text.slice(0,0)처럼 취급
console.log(text.slice(-5, 2)) // => ""

// indexStart가 -5이므로 문자열 길이 10 - 5의 5번째에서 시작
// indexEnd가 -2이므로 문자열 길이 10에서 10 - 2의 8번째에서 추출 종료.
// text.slice(5, 8)과 같은 결과
console.log(text.slice(-5, -2)) // => "wor"

slice

문자열의 일부를 추출하면서 새로운 문자열을 반환하기 때문에 다른 문자열에 영향을 미치지 않으며, 마지막 인덱스 직전까지 추출한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
const str = 'The quick brown fox jumps over the lazy dog.'

console.log(str.slice(31))
// expected output: "the lazy dog."

console.log(str.slice(4, 19))
// expected output: "quick brown fox"

console.log(str.slice(-4))
// expected output: "dog."

console.log(str.slice(-9, -5))
// expected output: "lazy"

str.slice(beginIndex[, endIndex])
str.slice(추출시작점 인덱스[, 종료인덱스, 주어진 인덱스 직전까지 추출])
return 값 : 추출된 부분을 담는 새로운 문자열

  • beginIndex
    • 음수가 올 경우, strLength(문자열 길이) + beginIndex로 취급 (e.g. beginIndex가 -3이면 시작점은 strLength -3)
    • beginIndex가 strLength보다 크면 빈 문자열 반환
  • endIndex
    • 추출 종료점 인덱스 위치의 문자는 추출에 포함되지 않고, 그 직전까지 추출된다.
    • endIndex가 생략되었다면 문자열 마지막까지 추출한다.
    • 음수라면 endIndex는 strLength + endIndex로 취급된다. (e.g. endInedx가 -3이면 종료점은 strLength -3)
1
2
3
4
5
6
7
8
9
10
11
12
13
const str1 = 'The morning is upon us.', // the length of str1 is 23.
str2 = str1.slice(1, 8),
str3 = str1.slice(4, -2),
str4 = str1.slice(12),
str5 = str1.slice(30)

console.log(str2) // OUTPUT: he morn
console.log(str3) // OUTPUT: morning is upon u
console.log(str4) // OUTPUT: is upon us.
console.log(str5) // OUTPUT: ""

//slice는 새로운 문자열은 반환하기 때문에 str1은 그대로이다.
console.log(str1) // OUTPUT: The morning is upon us.

음수 인덱스로 slice()사용

1
2
3
4
const str = 'The morning is upon us.'
str.slice(-3) // returns 'us.'
str.slice(-3, -1) // returns 'us'
str.slice(0, -1) // returns 'The morning is upon us'
  • beginIndex가 음수일 때
    beginIndex는 문자열의 끝에서부터 역방향으로 11개를 세고, endIndex는 시작점부터 순방향으로 16개를 센다.
1
console.log(str.slice(-11, 16)) // => "is u";
  • endIndex가 음수일 때
    beginIndex는 문자열의 시작점부터 순방향으로 11개를 세고, endIndex는 끝에서부터 역방향으로 7개를 센다.
1
console.log(str.slice(11, -7)) // => "is u";
  • beginIndex, endIndex 모두 음수일 때
    beginIndex는 끝에서부터 5까지 역순으로 계산하여 시작인덱스를 찾았고, 끝에서부터 1까지 역순으로 계산하여 마지막 인덱스를 찾는다. beginIndex가 -5이므로 문자열 길이 23 - 5의 18번째에서 시작한다. endIndex는 -1이므로 문자열 길이 23에서 23 - 1의 22번째에서 추출을 종료한다.
1
console.log(str.slice(-5, -1)) // => "n us";

References
String.prototype.substr()
String.prototype.substring()
String.prototype.slice()