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

타입 선언을 작성할 때 코드의 동작을 더 구체적으로 또는 덜 구체적으로 모델링하게 되는 상황이 있다. 일반적으로 타입이 구체적일수록 버그를 더 많이 잡고, 타입스크립트가 제공하는 도구를 활용할 수 있다. 그러나, 타입 선언의 정밀도를 높이는 것은 주의해야 한다. 실수가 발생하기 쉽고, 잘못된 타입은 차라리 타입이 없는 것보다 못할 수 있다.

📍 예시

🔗 GeoJSON 형식의 타입 선언(좌표 배열)

interface Point {
  type: 'Point';
  coordinates: number[];
}
interface LineString {
  type: 'LineString';
  coordinates: number[][];
}
interface Polygon {
  type: 'Polygon';
  coordinates: number[][][];
}
type Geometry = Point | LineString | Polygon;  // Also several others

좌표에 쓰이는 number[]가 추상적이다. number[]는 경도와 위도를 나타내므로 튜플 타입으로 선언하자.

type GeoPosition = [number, number];
interface Point {
  type: 'Point';
  coordinates: GeoPosition;
}
// Etc.

타입을 구체적으로 개선했지만, 위치 정보에 다른 정보가 있을 수도 있다. 결과적으로 타입 선언을 세밀하게 만들고자 했지만, 오히려 타입이 부정확해졌다.

🔗 JSON으로 정의된 Lisp와 비슷한 언어의 타입 선언

12
"red"
["+", 10, 5]    // 15
["/", 20, 2]    // 10
["rgb", 255, 128, 64]    // #FF007F
["case", [">", 20, 10], "red", "blue"] "red"

맵박스 라이브러리는 이런 시스템을 사용하여 수많은 기기에서 지도 기능의 형태를 결정한다. 다음은 이런 동작을 모델링해 볼 수 있는 입력값의 전체 종류이다.

1. 모두 허용
2. 문자열, 숫자. 배열 허용
3. 문자열, 숫자. 알려진 함수 이름으로 시작하는 배열 허용
4. 각 함수가 받는 매개변수의 개수가 정확한지 확인
5. 각 함수가 받는 매개변수의 타입이 정확한지 확인

✓ 1, 2 옵션

type Expression1 = any;
type Expression2 = number | string | any[];

표현식의 유효성을 체크하는 테스트 세트를 도입해보자. 타입을 구체적으로 만들수록 정밀도가 손상되는 것을 방지하는데 도움이 된다.

const okExpressions: Expression2[] = [
  10,
  "red",
  ["+", 10, 5],
  ["rgb", 255, 128, 64],
  ["case", [">", 20, 10], "red", "blue"],
];
const invalidExpressions: Expression2[] = [
  true,
// ~~~ Type 'boolean' is not assignable to type 'Expression2'
  ["**", 2, 31],  // Should be an error: no "**" function
  ["rgb", 255, 0, 127, 0],  // Should be an error: too many values
  ["case", [">", 20, 10], "red", "blue", "green"],  // (Too many values)
];

정밀도를 끌어 올리기 위해 튜플의 첫 번째 요소에 문자열 리터럴 타입의 유니온을 사용하자.

type FnName = '+' | '-' | '*' | '/' | '>' | '<' | 'case' | 'rgb';
type CallExpression = [FnName, ...any[]];
type Expression3 = number | string | CallExpression;

const okExpressions: Expression3[] = [
  10,
  "red",
  ["+", 10, 5],
  ["rgb", 255, 128, 64],
  ["case", [">", 20, 10], "red", "blue"],
];
const invalidExpressions: Expression3[] = [
  true,
  // Error: Type 'boolean' is not assignable to type 'Expression3'
  ["**", 2, 31],
  // ~~ Type '"**"' is not assignable to type 'FnName'
  ["rgb", 255, 0, 127, 0],  // Should be an error: too many values
  ["case", [">", 20, 10], "red", "blue", "green"],  // (Too many values)
];

정밀도를 유지하며 오류를 잡았다.

각 함수의 매개변수 개수가 정확한지 확인하기 위해 최소한 하나의 인터페이스를 추가해야 한다. 각 인터페이스를 나열해서 호출 표현식을 작성한다.

type Expression4 = number | string | CallExpression;

type CallExpression = MathCall | CaseCall | RGBCall;

type MathCall = [
  '+' | '-' | '/' | '*' | '>' | '<',
  Expression4,
  Expression4,
];

interface CaseCall {
  0: 'case';
  [n: number]: Expression4;
  length: 4 | 6 | 8 | 10 | 12 | 14 | 16; // etc.
}

type RGBCall = ['rgb', Expression4, Expression4, Expression4];
const okExpressions: Expression4[] = [
  10,
  "red",
  ["+", 10, 5],
  ["rgb", 255, 128, 64],
  ["case", [">", 20, 10], "red", "blue"],
];
const invalidExpressions: Expression4[] = [
  true,
// ~~~ Type 'boolean' is not assignable to type 'Expression4'
  ["**", 2, 31],
// ~~~~ Type '"**"' is not assignable to type '"+" | "-" | "/" | ...
  ["rgb", 255, 0, 127, 0],
  //                   ~ Type 'number' is not assignable to type 'undefined'.
  ["case", [">", 20, 10], "red", "blue", "green"],
  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  // Types of property 'length' are incompatible.
  //    Type '5' is not assignable to type '4 | 6 | 8 | 10 | 12 | 14 | 16'.
];

✏️ 무효한 표현식에서 전부 오류가 발생한다. 타입 정보가 더 정밀해졌지만 결과적으로 이전 버전보다 개선되지 않았다. 잘못 사용된 코드에서 오류가 발생하지만, 오류메시지가 더 난해해졌다. 새 타입 선언은 더 구체적이지만 자동 완성을 방해하므로 타입스크립트 개발 경험을 해친다.

📍 정리

타입을 정제할 때, 완벽을 추구하다 역효과가 나는 것을 주의하자. 일반적으로 any 같은 추상적인 타입은 정제하는 것이 좋지만 타입이 구체적으로 정제된다고 해서 정확도가 무조건 올라가지는 않는다. 타입에 의존하기 시작하면 부정확함으로 인해 발생하는 문제는 더 커진다.

📍 요약

  • 타입 안정성에서 불쾌한 골짜기는 피해야 한다. 타입이 없는 것보다 잘못된게 더 나쁘다.

  • 정확하게 타입을 모델링할 수 없다면, 부정확하게 모델링하지 말아야 한다. 또한 any와 unknown를 구별해서 사용해야 한다.

Last updated