Front-end Developer

0%

타입스크립트 이펙티브 아이템 21 - 아이템 27

아이템 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
[이펙티브 타입스크립트] 댄 밴더캄 지음