TL;DR
- よくある Branded types の実装では、ブランドをネストした型が
neverになる。 - ブランドのキーを
__brand_${B}のように型ごとに分けると、ネストしても正しく型付けできる。ただし型は冗長になる。
背景
単位がメートルの標高を表す型のように、Branded types をネストしたい場面があった。
export type Meter = Branded<number, "Meter">
export type Elevation = Branded<Meter, "Elevation">Branded types は普通、次のように実装する。しかしこの実装では、ネストした型(Elevation)が never になった。
declare const __brand: unique symbol
export type Branded<T, B extends string> = T & { readonly [__brand]: B }Elevation では __brand の値が "Meter" かつ "Elevation" でなければならない。そのような値は存在しないので、型が never になる。
検証
ブランドのキーを型ごとに分けるように定義した。
export type Branded<T, B extends string> = T & {
readonly [K in `__brand_${B}`]: B
}こうすると、Elevation は次のような型になる。
type Elevation = number & {
readonly __brand_Meter: true
} & {
readonly __brand_Elevation: true
}これは存在しうる型なので、never にはならない。
正しく型チェックされるかも確かめた。
async function getElevation(latlng: LatLng): Promise<Elevation> {
return 0
// ~~ Type 'number' is not assignable to type 'Elevation'.
}
async function getElevation(latlng: LatLng): Promise<Elevation> {
return 0 as Meter
// ~~~ Type 'Meter' is not assignable to type 'Elevation'.
}
async function getElevation(latlng: LatLng): Promise<Elevation> {
return 0 as Elevation // OK
}結果
number や Meter を Elevation として返すとエラーになり、Elevation を返すときだけ通った。
考察
この方法には欠点もある。__brand_${B} はただの文字列なので、別の型とキーが衝突するおそれがある。型が冗長で複雑になり、ブランドを外すときにも手間がかかる。