如何用TypeScript安全限制JavaScript对象的可用操作?
问题背景
我有一些JavaScript对象,它们提供多种操作,但部分操作的有效性取决于对象运行时的恒定状态。我希望用TypeScript静态限制这些对象的可用操作集,避免调用无效操作。
我试过用判别式联合+类型断言实现,但存在问题:类型断言会让TypeScript无法校验对象与联合类型的兼容性,比如字段拼写错误不会触发报错。另外,这个方案要用到惰性对象上——这类对象不能提前读取状态判断类型,否则会破坏惰性特性。
极简示例
基础封装类MyNum
我定义了MyNum类封装数字,其中incr()仅支持整数实例,round()仅支持非整数实例:
class MyNum { n: number; get isInt() { return this.n % 1 === 0; } constructor(n: number) { this.n = n; } incr() { if (!this.isInt) throw new Error(`incr() 只能调用在整数实例上`); return this.n + 1; } round() { if (this.isInt) throw new Error(`round() 只能调用在非整数实例上`); return Math.round(this.n); } }
直接用MyNum类型的话,TypeScript无法静态阻止调用不兼容的操作。
尝试用判别式联合实现
我定义了判别式联合Num来做静态校验,但必须把MyNum强制断言为该类型:
type NumBase = { n: number; isInt: boolean; } interface Int extends NumBase { isInt: true; // 判别式 incr(): number; } interface Real extends NumBase { isInt: false; // 判别式 round(): number; } type Num = Int | Real; function myNum(n: number): Num { return new MyNum(n) as unknown as Num; }
这个方案能实现静态校验,但无法保证Num和MyNum的兼容性——比如MyNum里字段拼写错了,TypeScript不会报错。
惰性对象的兼容性问题
对于惰性加载的LazyNum类,同样存在这个兼容性问题:
class LazyNum implements NumBase { protected readonly compute: () => number; protected _n: number | undefined; get n() { if (this._n === undefined) this._n = this.compute(); return this._n; } get isInt() { return this.n % 1 === 0; } constructor(compute: () => number) { this.compute = compute; } incr() { return this.n + 1; } round() { return Math.round(this.n); } } function lazyNum(compute: () => number): Num { return new LazyNum(compute) as unknown as Num; }
最终问题
如何用TypeScript以类型安全的方式限制JavaScript对象的可用操作?
解决方案:类型约束+类型守卫组合方案
核心思路是通过类型约束强制类实现联合类型的所有必要成员,同时用类型守卫在不破坏惰性的前提下完成类型窄化,彻底避免无校验的强制断言。
1. 确保类与联合类型的兼容性
先明确判别式联合的定义,再用类型工具约束类必须实现所有必要成员,让TypeScript自动校验兼容性:
// 定义核心判别式联合 interface Int { isInt: true; n: number; incr(): number; } interface Real { isInt: false; n: number; round(): number; } type Num = Int | Real; // 提取联合类型的公共成员,作为类的基础约束 type NumCommon = { [K in keyof Int & keyof Real]: Int[K] | Real[K] }; // 约束类必须实现公共成员 + 至少一个分支的专属方法 type NumImpl = NumCommon & (Pick<Int, "incr"> | Pick<Real, "round">); // 现在定义类时,TypeScript会强制校验所有字段和方法的兼容性 class MyNum implements NumImpl { n: number; // 明确返回true/false类型,符合判别式要求 get isInt(): true | false { return this.n % 1 === 0; } constructor(n: number) { this.n = n; } incr() { if (!this.isInt) throw new Error(`incr() 只能调用在整数实例上`); return this.n + 1; } round() { if (this.isInt) throw new Error(`round() 只能调用在非整数实例上`); return Math.round(this.n); } }
如果MyNum里的incr拼写错误(比如写成increment),TypeScript会直接抛出编译错误,彻底解决了兼容性校验缺失的问题。
2. 惰性对象的类型安全处理
惰性对象不能提前读取isInt(会触发计算),我们可以分两种场景处理:
场景1:能提前确定状态
如果在创建惰性对象时已经知道最终的isInt值,可以直接在工厂函数中指定类型,避免后续触发计算:
class LazyNum implements NumImpl { protected readonly compute: () => number; protected _n: number | undefined; get n() { if (this._n === undefined) this._n = this.compute(); return this._n; } get isInt(): true | false { return this.n % 1 === 0; } constructor(compute: () => number) { this.compute = compute; } incr() { return this.n + 1; } round() { return Math.round(this.n); } } // 工厂函数直接根据已知的isInt返回对应类型 function lazyNum(compute: () => number, isInt: boolean): Num { return isInt ? (new LazyNum(compute) as Int) : (new LazyNum(compute) as Real); }
这里的类型断言是安全的,因为我们已经通过参数确定了实例的最终状态,TypeScript会认可这种类型窄化。
场景2:无法提前确定状态
如果必须完全惰性(不能提前知道isInt),只能在调用操作前用类型守卫触发计算,但可以把校验逻辑封装起来,确保调用前必须经过类型检查:
// 类型守卫函数,触发计算并完成类型窄化 function isIntNum(num: Num): num is Int { return num.isInt; } // 封装工具函数,强制调用操作前先做类型校验 function withIntNum<T>(num: Num, fn: (int: Int) => T): T | undefined { if (isIntNum(num)) { return fn(num); } console.error("当前不是整数实例,无法调用incr()"); return undefined; } // 使用示例 const lazy = new LazyNum(() => 3.14); // lazy.incr(); // 静态编译错误,直接阻止无效调用 withIntNum(lazy, (int) => int.incr()); // 运行时提示错误,静态类型安全
3. 简洁的工厂函数写法
对于非惰性对象,直接用类型守卫做窄化,避免强制断言:
function myNum(n: number): Num { const instance = new MyNum(n); // 通过运行时判断完成类型窄化,无需强制断言 return instance.isInt ? instance : instance; }
TypeScript会自动识别instance.isInt的判别式作用,完成类型窄化,不需要额外的类型断言。
内容的提问来源于stack exchange,提问作者Blue Nebula

