Front-end Developer

0%

타입스크립트 이펙티브 아이템 35 - 아이템 41

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