Branded Types(品牌类型)
TS 是结构类型系统——只要两个类型的"形状"一致,它们就互相兼容。大多数情况下这很方便,但有时会让语义上完全不同的值可以互相替换,埋下 bug。
Branded Types 是在 TS 中模拟名义类型的技巧,通过给基础类型附加一个虚构的标记属性,让结构相同但语义不同的类型无法互换。
问题:结构类型的隐患
GIS 开发中,经纬度坐标是最典型的例子。经度(Longitude)和纬度(Latitude)在 JS 里都是 number,但顺序搞混就会产生完全错误的位置。
function createPoint(lng: number, lat: number) {
return { lng, lat }
}
const lng = 116.4
const lat = 39.9
// TS 不会报错,但参数传反了
createPoint(lat, lng) // ❌ 实际上创建了错误的点,北京变成了大洋中的某个点两个参数都是 number,TS 没有任何办法区分它们。
解决方案:Brand 标记
通过交叉类型附加一个虚构属性作为"品牌标记":
type Longitude = number & { readonly __brand: 'Longitude' }
type Latitude = number & { readonly __brand: 'Latitude' }__brand 属性只存在于类型层面,运行时不会真的出现在值上。它的唯一作用是让 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。更好的做法是提供一个工厂函数,在里面做真实的值校验:
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(火星坐标系)坐标结构完全一样,但直接混用会导致偏移。
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 都是字符串,但含义完全不同,混用后端会返回错误。
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 度)是常见的低级错误:
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 工具类型,减少重复:
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 里 Longitude 和 number 完全一样。
注意事项
Brand 类型的值在数学运算后会退回到基础类型,需要重新断言:
const a = 10 as Meters
const b = 20 as Meters
const c = a + b // 类型是 number,不是 Meters
const d = (a + b) as Meters // 需要重新标记所以 Brand 更适合用在边界处(函数参数、API 入参),而不是大量参与运算的内部变量上。

