Skip to content

Branded Types(品牌类型)

TS 是结构类型系统——只要两个类型的"形状"一致,它们就互相兼容。大多数情况下这很方便,但有时会让语义上完全不同的值可以互相替换,埋下 bug。

Branded Types 是在 TS 中模拟名义类型的技巧,通过给基础类型附加一个虚构的标记属性,让结构相同但语义不同的类型无法互换。

问题:结构类型的隐患

GIS 开发中,经纬度坐标是最典型的例子。经度(Longitude)和纬度(Latitude)在 JS 里都是 number,但顺序搞混就会产生完全错误的位置。

ts
function createPoint(lng: number, lat: number) {
  return { lng, lat }
}

const lng = 116.4
const lat = 39.9

// TS 不会报错,但参数传反了
createPoint(lat, lng)  // ❌ 实际上创建了错误的点,北京变成了大洋中的某个点

两个参数都是 number,TS 没有任何办法区分它们。

解决方案:Brand 标记

通过交叉类型附加一个虚构属性作为"品牌标记":

ts
type Longitude = number & { readonly __brand: 'Longitude' }
type Latitude  = number & { readonly __brand: 'Latitude' }

__brand 属性只存在于类型层面,运行时不会真的出现在值上。它的唯一作用是让 TS 把这两个类型视为不同的类型。

ts
function createPoint(lng: Longitude, lat: Latitude) {
  return { lng, lat }
}

const lng = 116.4 as Longitude
const lat = 39.9  as Latitude

createPoint(lng, lat)  // ✅
createPoint(lat, lng)  // ❌ Error: Argument of type 'Latitude' is not assignable to parameter of type 'Longitude'

配合工厂函数:封装校验逻辑

直接用 as 做类型断言只是把问题转移了——随便一个 number 都能断言成 Longitude。更好的做法是提供一个工厂函数,在里面做真实的值校验:

ts
function createLongitude(value: number): Longitude {
  if (value < -180 || value > 180) {
    throw new RangeError(`经度必须在 -180 到 180 之间,当前值:${value}`)
  }
  return value as Longitude
}

function createLatitude(value: number): Latitude {
  if (value < -90 || value > 90) {
    throw new RangeError(`纬度必须在 -90 到 90 之间,当前值:${value}`)
  }
  return value as Latitude
}

// 使用
const lng = createLongitude(116.4)
const lat = createLatitude(39.9)

createPoint(lng, lat)  // ✅ 类型安全,且值已经过校验

这样 as 断言被收敛在工厂函数内部,外部代码只能通过校验后才能拿到 Branded 类型的值。

GIS 场景扩展

坐标系标记

GIS 最常见的坑之一:WGS84 坐标系的坐标和 GCJ02(火星坐标系)坐标结构完全一样,但直接混用会导致偏移。

ts
type WGS84Coord = [Longitude, Latitude] & { readonly __crs: 'WGS84' }
type GCJ02Coord = [Longitude, Latitude] & { readonly __crs: 'GCJ02' }

// 在地图上显示,要求 GCJ02(高德、腾讯等国内地图)
function plotOnGaodeMap(coord: GCJ02Coord) {
  console.log('plotting:', coord)
}

// 从 GPS 设备拿到的是 WGS84
const gpsCoord = [116.4, 39.9] as unknown as WGS84Coord

plotOnGaodeMap(gpsCoord)  // ❌ Error:类型不匹配,提醒你需要做坐标转换

要素 ID

图层要素(Feature)的 ID、图层 ID、数据源 ID 都是字符串,但含义完全不同,混用后端会返回错误。

ts
type FeatureId  = string & { readonly __brand: 'FeatureId' }
type LayerId    = string & { readonly __brand: 'LayerId' }
type DataSourceId = string & { readonly __brand: 'DataSourceId' }

async function getFeature(layerId: LayerId, featureId: FeatureId) {
  // 向后端请求指定图层下的指定要素
}

async function deleteLayer(id: LayerId) { }

const fid = 'feature-001' as FeatureId
const lid = 'layer-abc'   as LayerId

getFeature(lid, fid)   // ✅
getFeature(fid, lid)   // ❌ 参数顺序搞反,编译期就报错
deleteLayer(fid)       // ❌ 传了 FeatureId,不是 LayerId

距离单位

空间分析中,距离单位混用(米 vs 千米 vs 度)是常见的低级错误:

ts
type Meters     = number & { readonly __brand: 'Meters' }
type Kilometers = number & { readonly __brand: 'Kilometers' }

function bufferPoint(center: [Longitude, Latitude], radius: Meters) {
  // 以 center 为圆心,radius 米为半径,生成缓冲区
}

const radiusKm = 1.5 as Kilometers
bufferPoint([116.4, 39.9] as any, radiusKm)  // ❌ 单位不对,编译期报错

const radiusM = 1500 as Meters
bufferPoint([116.4, 39.9] as any, radiusM)   // ✅

通用工具类型

可以提取成一个通用的 Brand 工具类型,减少重复:

ts
type Brand<T, B extends string> = T & { readonly __brand: B }

type Longitude    = Brand<number, 'Longitude'>
type Latitude     = Brand<number, 'Latitude'>
type Meters       = Brand<number, 'Meters'>
type Kilometers   = Brand<number, 'Kilometers'>
type FeatureId    = Brand<string, 'FeatureId'>
type LayerId      = Brand<string, 'LayerId'>
type WGS84Coord   = Brand<[number, number], 'WGS84'>
type GCJ02Coord   = Brand<[number, number], 'GCJ02'>

与其他方案对比

方案运行时开销类型安全使用复杂度
Branded Types无(纯类型层面)✅ 编译期检查低(一个工具类型搞定)
包装类(class)有(对象创建)✅ 名义类型高(每处都要 .value
枚举有(运行时对象)部分
注释/命名约定❌ 无保障

Branded Types 的核心优势是零运行时开销——as 只是给编译器看的,编译后的 JS 里 Longitudenumber 完全一样。

注意事项

Brand 类型的值在数学运算后会退回到基础类型,需要重新断言:

ts
const a = 10 as Meters
const b = 20 as Meters
const c = a + b  // 类型是 number,不是 Meters

const d = (a + b) as Meters  // 需要重新标记

所以 Brand 更适合用在边界处(函数参数、API 入参),而不是大量参与运算的内部变量上。