TypeScript: RxJS 中使用条件类型实现 Observable 的链式操作
pipe 的类型推导靠的是手写重载,不是条件类型,第 10 个操作符之后就退化成 Observable<unknown>。RxJS 里真正的条件类型在别处 —— filter(Boolean) 能把 null 从类型里摘掉,靠的就是它。
一条 pipe 链上的类型是会流动的:
const source$ = of(1, 2, 3, 4, 5); // Observable<number>
const result$ = source$.pipe( map(x => x * 2), // Observable<number> filter(x => x > 5), // Observable<number> map(x => x.toString()) // Observable<string>);
result$.subscribe(console.log); // 6, 8, 10中间每一步的 x 都不用标类型,最后 result$ 精确地是 Observable<string>。这份推导常被说成是条件类型的功劳 —— 其实不是。这里一个条件类型都没有,靠的是手写的函数重载。
pipe 的类型是一条条重载堆出来的
pipe 的声明大致是这个样子,每多支持一个操作符就多写一条:
interface Observable<T> { pipe<A>(op1: OperatorFunction<T, A>): Observable<A>; pipe<A, B>(op1: OperatorFunction<T, A>, op2: OperatorFunction<A, B>): Observable<B>; pipe<A, B, C>(op1: OperatorFunction<T, A>, op2: OperatorFunction<A, B>, op3: OperatorFunction<B, C>): Observable<C>; // …一直写到 9 个 pipe(...operations: OperatorFunction<any, any>[]): Observable<unknown>;}串联的机制全在类型参数的排布上:第一个操作符的输出叫 A,第二个的输入就必须也是 A,第三个的输入是第二个的输出 B。TypeScript 从实参依次推断这些变量,链条就接上了。中间任何一步接错类型,重载匹配不上,报的是这种错:
error TS2345: Argument of type 'OperatorFunction<number, number>' is not assignable to parameter of type 'OperatorFunction<number, string>'.关键是最后那条兜底签名。RxJS 只写到 9 个操作符,第 10 个开始就掉进 ...operations 那条,返回类型变成 Observable<unknown>,前面所有推导一次性全部作废。这不是传闻,RxJS 仓库里有一条专门的 issue 记着它(#5599,标题就叫「Type inference lost after 10 operators inside an observable pipe」)。
这是重载这条路的固有代价:重载是有限张表,写多少条就支持多少个。真正的解法要用可变元组类型,社区提过(#7481),但没有落地。实践上撞到这个限制的处理办法很朴素 —— 把长链拆成两段 pipe,每段各自推导,中间落一个有名字的变量。顺带一提,拆开之后可读性通常也更好,10 个操作符串在一起本来就不好读。
RxJS 里真正的条件类型在 filter(Boolean) 上
条件类型的语法是 T extends U ? X : Y。RxJS 用得最巧的一处是 filter(Boolean)。
先说这个写法为什么诱人。从一个可能有空值的流里把空值滤掉,最自然的写法就是 filter(Boolean):
const source$: Observable<string | null> = …;const clean$ = source$.pipe(filter(Boolean));运行时当然是对的,null 过不去。但类型上,一个普通的 filter 签名是 OperatorFunction<T, T> —— 进去什么类型,出来还是什么类型,clean$ 会是 Observable<string | null>,后面每次用还得再判一次空。RxJS 早年就是这个行为,仓库里那条 issue(#4959)标题写得很直白:filter(Boolean) loses type information。
现在 RxJS 给 filter 加了一条专门认 Boolean 的重载:
export function filter<T>(predicate: BooleanConstructor): OperatorFunction<T, TruthyTypesOf<T>>;TruthyTypesOf<T> 就是那个条件类型,定义在 types.ts 里,做的事是把假值类型从联合里摘出去:
type Falsy = null | undefined | false | 0 | -0 | 0n | '';type TruthyTypesOf<T> = T extends Falsy ? never : T;
type A = TruthyTypesOf<string | null | undefined>; // stringtype B = TruthyTypesOf<number | 0 | false>; // number这里有两个 TypeScript 的行为在配合:裸类型参数上的条件类型对联合类型是分配的 —— T 是 string | null | undefined 时,它不是整体去和 Falsy 比,而是拆成三份各判一次;判到 null 和 undefined 落进 never 分支。第二个行为是 never 在联合里会自动消失,string | never | never 就是 string。两条合起来,假值类型就被摘干净了。
于是 clean$ 的类型现在精确地是 Observable<string>,后面不用再判空。这才是条件类型在 RxJS 里真正干的活:不是把操作符串起来(那是重载的活),而是根据传进来的东西长什么样,算出一个不一样的输出类型。
同一条思路还用在 ObservedValueOf 这类工具类型上 —— 用 infer 把一个 ObservableInput 里面裹着的值类型掏出来,concatMap、switchMap 这些要展开内层流的操作符靠它写签名。
那段带 MapOperator 的 map 实现是 RxJS 6 的
讲 RxJS 操作符实现时经常会看到这样一段:
export function map<T, R>(project: (value: T, index: number) => R): OperatorFunction<T, R> { return function mapOperation(source: Observable<T>): Observable<R> { return source.lift(new MapOperator(project, thisArg)); };}
class MapOperator<T, R> implements Operator<T, R> { call(subscriber: Subscriber<R>, source: any): any { return source.subscribe(new MapSubscriber(subscriber, this.project, this.thisArg)); }}RxJS 7 把这一整套换掉了:Operator 接口和「造一个 XxxOperator 实例交给 source.lift」的模式不再是操作符的写法,改成了一个叫 operate 的内部辅助函数直接接管订阅。所以拿着 MapOperator 去 RxJS 7 的仓库里搜是搜不到的,照着它写自定义操作符也已经不是现在的写法。
这件事和类型没有直接关系,但它决定了你按文章去仓库里对照时能不能对上号 —— 版本对不上,看到的东西就完全不同。
代价
- 重载表是有限的,而且边界很硬。 9 个以内类型精确,第 10 个直接掉到
Observable<unknown>,中间没有过渡,也不会有任何警告。链长逼近这个数时就该拆了。 - 包一层
pipe会丢类型。 想写一个「把常用操作符打包」的工具函数,参数得自己再写一遍重载,否则内部那次pipe拿到的是OperatorFunction<any, any>[],返回类型退化成unknown。这是自定义操作符最常见的类型麻烦。 filter(Boolean)只摘假值类型,不认业务上的「空」。 它按Falsy那张表判,0和''也会被当成要滤掉的东西 —— 数值流里这经常不是你想要的。要按别的条件收窄,得自己写类型谓词:filter((x): x is User => x !== null)。- 类型推断和运行时行为是两件事。 类型说得出
Observable<string>,说不出这个流会不会发出值、什么时候完成、出错走哪条路。RxJS 里真正难的部分(订阅时序、退订、错误传播)类型系统一点忙都帮不上。
这篇归在 TypeScript 下。同一主题里最近的另外两篇是 TypeScript:从架构分层设计到 IOC 和 AOP 和 Typescript:Nest.js 中使用声明文件定义依赖注入的类型。