어떤 함수는 타임아웃을 밀리초로 요구한다. 5000을 넘긴다. 나중에 다른 함수가 타임아웃을 초 단위로 요구한다. 5를 넘긴다. 그 사이에 setTimeout(duration, callback)을 호출하면, 한 시간 스물세 분 동안 아무 일도 일어나지 않는다.
TypeScript는 여기서 도움이 되지 않는다. 5000과 5는 둘 다 number다. 컴파일러는 미터 단위 거리와 피트 단위 거리, 섭씨 온도와 화씨 온도, 타임스탬프와 지속 시간 사이의 차이를 알 수 없다. 테스트 스위트도 이를 잡아내지 못할 가능성이 높은데, 계산 자체는 올바르기 때문이다. 단위가 잘못됐을 뿐이다.
해결책은 단위를 문서가 아닌 타입으로 다루기 시작하는 것이다.
왜 number가 물리량에 적합하지 않은 타입인가
TypeScript는 structural typing을 사용한다. 두 객체의 모양이 일치하면 호환된다. 이것은 보통 장점이지만, number 같은 기본형에 대해서는 모든 숫자가 서로 교환 가능하다는 의미가 된다. number는 number이고, 그게 또 number다.
Runtime check로 단위 오류를 잡을 수는 있지만, 유지보수 비용이 크고 걸러내기 쉽다. 모든 함수 인자, 모든 API 응답, 다른 파일에 정의된 모든 상수를 검증해야 한다. 실제로 아무도 이렇게 하지 않는다. 검증은 주석이 되고, 주석은 거짓말을 한다.
대안은 단위를 타입에 직접 인코딩하는 것이다. 컴파일 시점에 Seconds와 Milliseconds는 서로 호환되지 않는 타입이 된다. Meters를 Meters와 곱하면 SquareMeters가 된다. Miles에 Kilometers를 더하면 컴파일러가 거부한다. 런타임에는 여전히 값은 그냥 숫자다. 래퍼 객체도, 런타임 검증도, 성능 비용도 없다. 이것이 zero-cost abstraction이다.
phantom type이 숫자를 branded unit으로 만드는 법
TypeScript는 기본형에 대한 nominal typing을 지원하지 않지만, intersection type과 unique symbol은 지원한다. 원시값에 brand를 입혀서 기본 값이 같아도 두 brand가 서로 호환되지 않게 만들 수 있다.
패턴은 다음과 같다:
type Brand<T, B> = T & { readonly __brand: B };
type Meters = Brand<number, "Meters">;
type Kilometers = Brand<number, "Kilometers">;
type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;
__brand 프로퍼티는 런타임에 존재하지 않는다. 이것은 phantom type이다. 타입 시스템에서만 존재한다. 하지만 이것만으로도 Meters와 Kilometers를 서로 호환되지 않게 만들기에 충분하다.
일반 number를 branded type에 실수로 할당할 수 없다. 이것이 핵심이다. 명시적으로 생성해야 하며, 이는 단위를 반드시 명시하도록 강제한다.
TypeScript에서 동작하는 단위 시스템
생성, 변환, 산술 연산을 처리하는 최소하지만 완전한 구현이다.
type Brand<T, B> = T & { readonly __brand: B };
type Meters = Brand<number, "Meters">;
type Kilometers = Brand<number, "Kilometers">;
type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;
type MetersPerSecond = Brand<number, "MetersPerSecond">;
function meters(value: number): Meters {
return value as Meters;
}
function kilometers(value: number): Kilometers {
return value as Kilometers;
}
function seconds(value: number): Seconds {
return value as Seconds;
}
function milliseconds(value: number): Milliseconds {
return value as Milliseconds;
}
function toMeters(km: Kilometers): Meters {
return meters(km * 1000);
}
function toSeconds(ms: Milliseconds): Seconds {
return seconds(ms / 1000);
}
function toMilliseconds(s: Seconds): Milliseconds {
return milliseconds(s * 1000);
}
function addMeters(a: Meters, b: Meters): Meters {
return meters(a + b);
}
function speed(distance: Meters, time: Seconds): MetersPerSecond {
return (distance / time) as MetersPerSecond;
}
사용 예시:
const d1 = kilometers(5);
const d2 = meters(200);
const t = seconds(10);
// 이것은 컴파일된다.
const totalDistance = addMeters(toMeters(d1), d2);
const velocity = speed(totalDistance, t);
// 이것은 컴파일되지 않는다.
const bad = addMeters(d1, d2);
// ^^^ Argument of type 'Kilometers' is not assignable to parameter of type 'Meters'.
const alsoBad = speed(totalDistance, milliseconds(5000));
// ^^^^^ Argument of type 'Milliseconds' is not assignable to parameter of type 'Seconds'.
오류는 버그가 발생한 곳에서 나타나지, 값이 최종 사용되는 곳에서 나타나지 않는다. 누군가 초 단위 매개변수에 밀리초를 넘겼는지 찾기 위해 velocity를 세 개의 파일을 거슬러 추적할 필요가 없다.
기본 단위에서 복합 단위 도출하기
이 패턴은 복합 단위로도 확장된다. MetersPerSecond를 손으로 직접 작성하는 대신, generic constructor를 사용해 기본 타입에서 도출할 수 있다.
type Per<A, B> = Brand<number, { numerator: A; denominator: B }>;
type Times<A, B> = Brand<number, { left: A; right: B }>;
type MetersPerSecond = Per<Meters, Seconds>;
type SquareMeters = Times<Meters, Meters>;
function per<A, B>(numerator: Brand<number, A>, denominator: Brand<number, B>): Per<A, B> {
return (numerator / denominator) as Per<A, B>;
}
function times<A, B>(left: Brand<number, A>, right: Brand<number, B>): Times<A, B> {
return (left * right) as Times<A, B>;
}
실제로는 완전한 dimensional analysis가 필요하지 않을 수 있다. 대부분의 팀은 열 두 개 정도의 단위 타입 이후에는 수익 체감을 겪는다. 목표는 물리를 모델링하는 것이 아니다. 목표는 가장 비싼 종류의 버그를 제거하는 것이다: 계산은 맞지만 단위가 틀린 그 버그.
알아야 할 트레이드오프
Branded type은 공짜가 아니다. ergonomics를 비용으로 치른다.
모든 리터럴은 constructor로 감싸야 한다. setTimeout(callback, 5000)은 setTimeout(callback, milliseconds(5000))이 된다. 더 많은 타이핑이 필요하다. 팀이 constructor 사용에 일관성이 없으면, 코드베이스 전체에 unsafe cast가 흩어지게 된다. 이 패턴은 모두가 사용할 때만 작동한다.
타입 추론도 시끄러워진다. 배열 메서드와 generic function이 오류 메시지에서 brand를 드러낼 수 있다. 평범한 number[]가 (number & { readonly __brand: "Milliseconds" })[]보다 읽기 쉽다. 서명을 읽기 쉽게 유지하려면 type alias가 필요할 수 있다.
Serialization은 또 다른 마찰 지점이다. JSON은 branded type이라는 개념이 없다. Meters 값을 네트워크로 보내면 반대편에서는 평범한 number로 도착한다. 경계에서 brand를 재구성해야 한다. 그것이 올바른 위치이지만, 추가 코드가 필요하다.
가장 큰 한계는 이것이 TypeScript 전용 기법이라는 점이다. 시스템에 Python 서비스, Go 마이크로서비스, 또는 순수 JavaScript 클라이언트가 포함되어 있다면, brand는 언어 경계에서 사라진다. 시스템 경계에서 여전히 runtime validation이 필요하다. Branded type은 내부 TypeScript 코드를 보호한다. 외부 데이터에 대한 schema를 대체하지는 않는다.
팀원을 귀찮게 하지 않고 도입하는 방법
코드베이스의 모든 숫자에 brand를 입히려 하지 마라. 실제 사고를 일으킨 매개변수부터 시작하라.
- 프로덕션에서 최근 발생한 세 가지 단위 관련 버그를 파악하라. 밀리초 대 초, 다른 단위의 통화, 위도 대 경도, 화면 좌표 대 문서 좌표를 찾아보라.
- 해당 타입에 brand를 입혀라. constructor와 변환 함수를 추가하라.
- 해당 값이 사용되는 함수를 업데이트하라. 컴파일러가 안내하도록 하라.
- 해당 매개변수에 대해 raw
number를 금지하는 lint rule을 추가하라.
루프 카운터, 배열 인덱스, 퍼센트에는 brand를 입히지 마라. 이것들은 무차원(dimensionless)이다. 거기에 brand를 추가하는 것은 가치 없는 의식일 뿐이다.
더 강력한 nominal typing을 가진 언어에서 작업한다면 더 나은 옵션이 있다. Rust 사용자는 uom crate를 살펴봐야 한다. F#과 OCaml은 컴파일러에 units of measure가 내장되어 있다. TypeScript의 structural type system은 이것을 first-class feature가 아닌 workaround로 만든다. 하지만 이 workaround는 실제 버그를 잡기에 충분히 좋다.
FAQ
런타임 오버헤드가 추가되나요?
아니요. brand는 컴파일 타임 전용 구조물이다. 컴파일 후 meters(100)은 그냥 숫자 100이다. 래퍼 객체도, 추가 프로퍼티도, 런타임 검증도 없다.
곱셈과 나눗셈은 어떻게 하나요?
명시적인 함수나 overloaded operator가 필요하다. TypeScript는 operator overloading을 지원하지 않으므로, distance / time은 반드시 per()나 speed() 함수를 거쳐야 한다. 이것은 장황하지만, 버그가 잡히는 이유이기도 하다.
서드파티 라이브러리와 함께 사용할 수 있나요?
라이브러리가 branded type을 받아들일 때만 가능하다. setTimeout이 number를 기대한다면, brand가 number와의 intersection이므로 Milliseconds를 넘길 수 있다. 반대는 성립하지 않는다. 라이브러리가 number를 반환한다면, unit type으로 사용하기 전에 명시적으로 brand를 입혀야 한다.
HalfSeconds 같은 분수 단위는 어떻게 처리하나요?
기본 단위와 constructor를 사용하라. halfSeconds(1)은 Milliseconds(500)을 반환한다. 모든 세분화에 brand를 만들지 마라. 기본 단위의 수를 적게 유지하라.
변수 이름에 단위 이름을 쓰지 마라
변수를 timeoutInMs라고 부르는 것은 문서다. 문서는 변질된다. timeout: Milliseconds라고 부르는 것은 타입이다. 타입은 강제된다.
다음에 이슈를 디버깅하다가 타임아웃이 천 배 차이가 났다는 것을 알게 되면, 변수 이름만으로 충분했는지 자문하라. 충분하지 않았다. 단위를 타입에 인코딩하고, 컴파일러가 일하도록 하라. 그리고 이 특정 함수가 초를 원하는지 밀리초를 원하는지 기억하려는 자신을 믿지 마라.