아이템 34 부정확한 타입보다는 미완성 타입을 사용하기
타입이 구체적일수록 버그를 더 많이 잡고 타입스크립트가 제공하는 도구를 활용할 수 있게 된다.
하지만 부정확한 타입 설계는 없는 것보다 못하다
그리고 타입정보를 구체적으로 만들 수록 오류 메시지와 자동 완성 기능에 주의를 기울여야 한다.
예시 1. 타입을 구체적으로 개선한 코드
interface Point {
type: 'Point';
coordinates: number[];
}
interface Linestring {
type: 'LineString';
coordinates: number[][];
}
interface Polygon {
type: 'Polygon';
coordinates: number[][][];
}
type Geometry = Point | LineString | Polygon;
-> 좌표로 쓰이는 number[]가 추상적
여기서 number[] 경도와 위도를 나타내므로 튜플 타입으로 선언하는게 낫다.
type GeoPosition = [number, number];
interface Point {
type: 'Point';
coordinates: GeoPosition;
}
타입을 구체적으로 개선했기 때문에 더 나은 코드가 됨
예시 2. JSON으로 정의된 Lisp와 비슷한 언어의 타입 선언
Lisp
리스프(Lisp, LISP) 혹은 리습은 프로그래밍 언어의 하나로, 오랜 역사와 독특하게 괄호를 사용하는 문법으로 유명하다.
Lisp은 “LISt Processing”(리스트 프로세싱)의 줄임말.
type Expression4 = number | string | CallExpression;
type CallExpression = MathCall | CaseCall | RGBCall;
interface MathCall {
0: '+' | '-' | '/' | '*' | '>' | '<';
1: Expression4;
2: Expression4;
length: 3;
}
interface CaseCall {
0: 'case';
1: Expression4;
2: Expression4;
3: Expression4;
length: 4 | 6 | 8 | 10 | 12 | 14 | 16 // etc.
}
interface RGBCall {
0: 'rgb';
1: Expression4;
2: Expression4;
3: Expression4;
length: 4;
}
const tests: Expression4[] = [
10,
"red",
true,
// ~~~ Type 'true' is not assignable to type 'Expression4'
["+", 10, 5],
["case", [">", 20, 10], "red", "blue", "green"],
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Type '["case", [">", ...], ...]' is not assignable to type 'string'
["**", 2, 31],
// ~~~~~~~~~~~~ Type '["**", number, number]' is not assignable to type 'string
["rgb", 255, 128, 64],
["rgb", 255, 128, 64, 73]
// ~~~~~~~~~~~~~~~~~~~~~~~~ Type '["rgb", number, number, number, number]'
// is not assignable to type 'string'
];
const okExpressions: Expression4[] = [
['-', 12],
// ~~~~~~~~~ Type '["-", number]' is not assignable to type 'string'
['+', 1, 2, 3],
// ~~~~~~~~~~~~~~ Type '["+", number, ...]' is not assignable to type 'string'
['*', 2, 3, 4],
// ~~~~~~~~~~~~~~ Type '["*", number, ...]' is not assignable to type 'string'
];
CaseCall 타입이 사용된 요소에서는 배열의 길이가 초과하여 오류가 발생하였는데,
오류로 표현되어야 할 green이 아닌 MathCall 타입의 [”>”, 20, 10] 요소가 오류로 지목되었다.
Good
코드를 더 정밀하게 만들려던 시도가 너무 과했고 그로 인해 코드가 오히려 더 부정확해졌다.
이렇게 부정확함을 바로잡는 방법을 쓰는 대신, 테스트 세트를 추가하여 놓친 부분이 없는지 확인하는 것이 나은 방법일 수 있다.
아이템 35 데이터가 아닌, API와 명세를 보고 타입 만들기
여기서 핵심은 예시 데이터가 아니라 명세를 참고해 타입을 생성한다는 것
예시 데이터를 참고해 타입을 생성하면 눈앞에 있는 데이터들만 고려하게 되므로 예기치 않은 곳에서 오류가 발생할 수 있으나
명세를 이용해 타입을 생성하면 타입스크립트는 사용자가 실수를 줄일 수 있게 도와준다.
예시. GitHub 저장소에서 오픈 소스 라이선스를 조회하는 쿼리
GraphQL API는 타입스크립트와 비슷한 타입 시스템을 사용하여, 가능한 모든 쿼리와 인터페이스를 명세하는 스키마로 이루어진다.
우리는 이러한 인터페이스를 사용해서 특정 필드를 요청하는 쿼리를 작성한다.
GraphQL의 장점은 특정 쿼리에 대해 타입스크립트 타입을 생성할 수 있다는 것이다.
GeoJSON 예제와 마찬가지로 GraphQL을 사용한 방법도 타입에 null이 가능한지 여부를 정확하게 모델링할 수 있다.
query getLicense($owner: String!, $name:String!) {
repository(owner:$owner, name:$name) {
description
licenseInfo {
spdxId
name
}
}
}
$owner와 $name은 타입이 정의된 GraphQL의 변수이다.
String은 GraphQL의 타입으로 타입스크립트의 string에 대응
타입 뒤의 ’!’는 null이 아님을 명시
Apollo는 GraphQL 쿼리를 타입스크립트 타입으로 변환해 주는 도구 중 하나
쿼리에서 타입을 생성하려면 GraphQL 스키마가 필요하다.
Apollo는 api.github.com/graphql로 부터 스키마를 얻는다.
$ apollo client:codegen \
—endpoint https: //api. git hub. com/graphql \
—includes license.graphql \
--target typescript
실행결과
export interface getLicense_repository_licenseInfo {
__typename: "License";
/** Short identifier specified by <https://spdx.org/licenses> */
spdxId: string | null;
/** The license full name specified by <https://spdx.org/licenses> */
name: string;
}
export interface getLicense_repository {
__typename: "Repository";
/** The description of the repository. */
description: string | null;
/** The license associated with the repository */
licenseInfo: getLicense_repository_licenseInfo | null;
}
export interface getLicense {
/** Lookup a given repository by the owner and repository name. */
repository: getLicense_repository | null;
}
export interface getLicenseVariables {
owner: string;
name: string;
}
주목할 만한 점은 다음과 같다.
- 쿼리 매개변수(getLicenseVariables)와 응답(getLicense) 모두 인터페이스가 생성되었다.
- null 가능 여부는 스키마로부터 응답 인터페이스로 변환되었다.
- 편집기에 확인할 수 있도록 주석은 JSDoc으로 변환되었다(아이템 48), 이 주석들은 GraphQL 스키마로부터 생성되었다.
=> 자동으로 생성된 타입 정보는 API를 정확히 사용할 수 있도록 도와준다.
쿼리가 바뀌면 타입도 자동으로 바뀌며, 스키마가 바뀌면 타입도 자동으로 바뀝니다.
아이템 36 해당 분야의 용어로 타입 이름 짓기
엄선된 타입, 속성, 변수의 이름은 의도를 명학히 하고 코드와 타입의 추상화 수준을 높여 준다.
잘못 선택한 타입 이름은 코드의 의도를 왜곡하고 잘못된 개념을 심어주게 된다.
코드로 표현하고자 하는 모든 분야에는 주제를 설명하기 위한 전문 용어들이 있다.
이런 용어들을 사용하면 소통에 유리하고 타입의 명확성을 올릴 수 있다.
주의할 점 3가지
- 동일한 의미를 나타낼 때는 같은 용어를 사용한다.
- data, info, thing, item, object, entity 같은 모호하고 의미없는 이름은 피한다.
만약 entity라는 용어가 해당 분야에서 특별한 의미를 가진다면 괜찮다.
- 이름을 지을 때는 포함된 내용이나 계산 방식이 아니라 데이터 자체가 무엇인지를 고려해야 한다.
예를 들어, INodeList 보다는 Directory가 더 의미 있는 이름.
Directory는 구현의 측면이 아닌 개념적인 측면에서 보는 것
예시. 동물들의 데이터베이스를 구축한다고 가정해 보자
Bad
- name: 매우 일반적인 용어, 동물의 학명인지 일반적인 명칭인지 알 수 없음
- endangered: 멸종 위기를 표현하기 위해 boolean 타입을 사용한 것이 맞지 않음
- habitat: 너무 넓은 범위의 string 타입이며, 서식지를 뜻하는 지도 불분명
interface Animal {
name: string;
endangered: boolean;
habitat: string;
}
const leopard: Animal = {
name: 'Snow Leopard',
endangered: false,
habitat: 'tundra',
};
Good
- name을 commonName, genus, species 등 더 구체적인 용어로 대체
- endangered를 status로 바꾸고, 동물 보호 등급에 대한 표준 체계를 사용하였다.
- habitat을 climates으로 바꾸고 쾨펜 기후 분류를 사용하였다.
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' | 'Cfb' | 'Cfc' | 'Csa' | 'Csb' | 'Csc' | 'Cwa' | 'Cwb' | 'Cwc' |
'Dfa' | 'Dfb' | 'Dfc' | 'Dfd' |
'Dsa' | 'Dsb' | 'Dsc' | 'Dwa' | 'Dwb' | 'Dwc' | 'Dwd' |
'EF' | 'ET';
const snowLeopard: Animal = {
commonName: 'Snow Leopard',
genus: 'Panthera',
species: 'Uncia',
status: 'VU', // vulnerable
climates: ['ET', 'EF', 'Dfd'], // alpine or subalpine
};
아이템 37 공식 명칭에는 상표를 붙이기
타입스크립트는 구조적 타이핑을 사용하기 때문에, 값을 세밀하게 구분하지 못하는 경우가 있다.
값을 구분하기 위해 공식 명칭이 필요하다면 상표를 붙이는 것을 고려해야 한다.
상표 기법은 타입 시스템에서 동작하지만 런타임에서 상표를 검사하는 것과 동일한 효과를 얻을 수 있다.
타입 시스템이기 때문에 런타임 오버헤드를 없앨 수 있고 추가 속성을 붙일 수 없는 string이나 number 같은 내장 타입도 상표화 할 수 있다.
구조적 타이핑
: 실제 구조와 정의에 의해 결정되는 타입 시스템
예시 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
Good
calculateNorm 함수가 3차원 벡터를 허용하지 않게 하려면 공식 명칭(nominal-typing)을 사용하면 된다.
공식 명칭을 사용하는 것은, 타입이 아니라 값의 관점에서 Vector2D라고 말하는 것
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...
그러나 vec3D 값에 _brand: '2d'라고 추가하는 것 같은 악의적인 사용을 막을 수는 없다.
다만 단순한 실수를 방지하기에는 충분하다.
예시 2. 절대 경로를 사용해 파일 시스템에 접근하는 함수
Good
string 타입이면서 _brand 속성을 가지는 객체를 만들 수는 없다.
타입 AbsolutePath는 온전히 타입 시스템의 영역
만약 path 값이 절대 경로와 상대 경로 둘 다 될 수 있다면, 타입을 정제해 주는 타입 가드를 사용해서 오류를 방지할 수 있다.
type AbsolutePath = string & {_brand: 'abs'};
function listAbsolutePath(path: AbsolutePath) {
// ...
}
function isAbsolutePath(path: string): path is AbsolutePath {
return path.startsWith('/');
}
function f(path: string) {
if (isAbsolutePath(path)) {
listAbsolutePath(path);
}
listAbsolutePath(path);
// ~~~~ Argument of type 'string' is not assignable
// to parameter of type 'AbsolutePath'
}
예시 3. number 타입에 상표 붙이기
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); // 타입이 Meters
const oneMin = seconds (60); // 타입이 Seconds
하지만, number 타입에 상표를 붙여도 산술 연산 후에는 상표가 없어지기 때문에 실제로 사용하기에는 무리가 있다.
그러나 숫자의 단위를 문서화하는 괜찮은 방법일 수 있다.
const tenKm = oneKm * 10; // 타입이 number
const v = oneKm / oneMin; // 타입이 number
5장 any 다루기
아이템 38 any 타입은 가능한 한 좁은 범위에서만 사용하기
요약
- 의도치 않은 타입 안전성의 손실을 피하기 위해서 any의 사용 범위를 최소한으로 좁혀야 합니다.
- 함수의 반환 타입이 any인 경우 타입 안정성이 나빠집니다. 따라서 any 타입을 반환하면 절대 안 됩니다.
- 강제로 타입 오류를 제거하려면 any 대신 @ts-ignore 사용하는 것이 좋습니다.
예시 1. 함수 내에서 사용된 any
function f1() {
const x: any = expressionReturningFoo(); // 이렇게 하지 맙시다.
processBar(x);
}
function f2() {
const x = expressionReturningFoo();
processBar(x as any); // 이게 낫습니다.
}
f1 함수: x를 any 타입으로 지정
f2 함수: x가 사용되는 곳에 as any 단언문 사용
f2 함수가 권장되는 이유:
any 타입이 processBar 함수의 매개변수에서만 사용이 되기 때문에, 다른 코드에는 영향이 미치지 않음 !
반면에, f1 함수에서는 마지막까지 x가 any 타입이 유지된다.
f1 함수가 x를 반환한다면?
function f1() {
const x: any = expressionReturningFoo();
processBar(x);
return x;
function g() {
const foo = f1(); // 타입이 any
foo.fooMethod(); // 이 함수 호출은 체크되지 않습니다!
}
=> 함수 호출이 체크되지 않기 때문에 이렇게 사용하면 안됨
이렇게 any 타입인 x를 반환하는 함수를 사용하면 foo 타입까지 영향을 미치기 때문에, 프로젝트 전반에 전염병처럼 퍼지게 된다.
f2 함수 처럼 사용한다면 any 타입이 함수 바깥으로 영향을 미치지 않는다.
function f1() {
const x = expressionReturningFoo();
// @ts-ignore
processBar(x);
return x;
}
@ts-ignore 를 사용하여 오류를 무시할 수 있지만, 근본적인 원인을 해결한 것은 아니기 때문에 다른 곳에 더 큰 문제가 발생할 수 있음
예시 2. 객체와 관련한 any 사용법
const config: Config = {
a: 1,
b: 2,
c: {
key: value
// ~
'foo' 속성이 'Foo' 타입에 필요하지만 'Bar' 타입에는 없습니다.
}
};
단순히 생각하면 config 객체 전체를 as any로 선언해서 오류를 제거할 수 있다.
const config: Config = {
a: 1,
b: 2,
c: {
key: value
}
} as any; // 이렇게 하지 맙시다!
하지만, 객체 전체를 any로 단언하면 다른 속성들 (a, b) 역시 타입 체크가 되지 않는다 !
그러므로 아래처럼 최소한의 범위에만 any를 사용하는 것이 좋다.
const config: Config = {
a: 1,
b: 2, // 이 속성은 여전히 체크됩니다.
c: {
key: value as any
}
};
아이템 39 any를 구체적으로 변형해서 사용하기
요약
- any를 사용할 때는 정말로 모든 값이 허용되어야만 하는지 면밀히 검토해야 한다.
- any보다 더 정확하게 모델링 할 수 있도록 any[] 또는 {[id: string]: any} 또는 () => any처럼 구체적인 형태를 사용해야한다.
1. any를 사용할 때는 정말로 모든 값이 허용되어야만 하는지 면밀히 검토해야 한다.
- any 타입은 모든 숫자, 문자열, 배열, 객체, 정규식, 함수, 클래스, DOM 엘레먼트, null, undefined 까지 포함한다.
- 일반적인 상황에서 any 보다 더 구체적으로 표현할 수 있는 타입이 존재할 가능성이 높다.
const numArgsBad = (...args: any) => args.length; //❌ return 타입이 any
const numArgsGood = (...args: any[]) => args.length; //✅ return 타입이 number
/**
후자가 더 좋은 이유
1. args.length 타입이 체크 됨
2. 함수 반환 타입이 number 로 추론 됨
3. 함수 호출 시, 매개변수가 배열인지 체크 됨
*/
2. any보다 더 정확하게 모델링 할 수 있도록 any[] 또는 {[id: string]: any} 또는 () => any처럼 구체적인 형태를 사용해야한다.
(그냥 any 보다는 조금이라도 더 구체화시키려 노력하자.)
아이템 40. 함수 안으로 타입 단언문 감추기
요약
- 타입 단언문은 일반적으로 타입을 위험하게 만들지만 상황에 따라 필요하기도 하고 현실적인 해결책이 되기도 한다. 불가피하게 사용해야 한다면, 정확한 정의를 가지는 함수 안으로 숨기도록 한다.
- 가능한 안전한 타입으로 구현하는 것이 이상적이나, 불필요한 예외 상황까지 고려해 가며 타입 정보를 힘들게 구성할 필요는 없다.
- 함수 내부에서는 타입 단언 을 사용하고, 함수 외부로 드러나는 타입 정의를 명시하는 정도가 적절할 수 있다.
//예제 - 함수 캐싱하는 함수: 함수가 자신의 마지막 호출을 캐시(기억)하도록 만들어주는 함수
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;
}
//원본 함수 타입 T와 리턴하는 함수가 어떤 관련이 있는지 모르기 때문에 에러 발생!
//그려나 우리는 두 함수가 '같은 매개변수를 주면 같은 값을 반환하는' 함수여서
//동일하게 취급해도 문제 없다는 것을 알기때문에 단언문 사용해도 괜찮다.
'Javascript & Typescript' 카테고리의 다른 글
[Typescript] 이펙티브타입스크립트 (#41 ~ #47) (2) | 2024.11.25 |
---|---|
[Typescript] 이펙티브타입스크립트 (#1 ~ #5) (1) | 2024.09.14 |
[javascript] forEach, map, filter, reduce 메서드 작동원리 (1) | 2023.03.02 |