尝试用 TypeScript 模拟一个带 Payload 的枚举
这两天逛 GitHub 时突然又注意到了 folktale ,发现它现在带了一个 adt/union 。看起来 folktale 现在的 Maybe, Result, Validation 都是从 union 里派生出来的,使用方法和 Swift/Rust 里的枚举非常相似:
const Either = union("Either", {
Left: (value) => ({ value }),
Right: (value) => ({ value }),
});
console.log(
Either.Right(1).matchWith({
Left: ({ value }) => `left ${value}`,
Right: ({ value }) => `right ${value}`,
})
);
正好我想在 TypeScript 里模拟一个带 Payload 的枚举机制已经想了很久了,觉得这个 API 设计得不错,可惜的是没有 TypeScript 的类型定义,在各种抓耳挠腮之下写了一个不完美的版本(下面解释):
// 这是一个假的函数,只是做个占位而已
export function union<P extends BaseUnionPatterns>(
typeId: string,
patterns: P
): UnionTypes<P> {
return null as any;
}
export type UnionModelMatchPatterns<P extends BaseUnionPatterns, R> = {
[K in keyof P]: (patternFnReturn: ReturnType<P[K]>) => R;
};
export type UnionModelMatchPatternsWithAny<P extends BaseUnionPatterns, R> = {
[K in keyof P]?: (patternFnReturn: ReturnType<P[K]>) => R;
} & {
$any: (patternFnReturn: ReturnType<P[keyof P]>) => R;
};
export type UnionInstance<P extends BaseUnionPatterns, Type extends keyof P> = {
equals: (unionInstance: UnionInstance<P, any>) => boolean;
matchWith: <R>(
branches:
| UnionModelMatchPatterns<P, R>
| UnionModelMatchPatternsWithAny<P, R>
) => R;
};
export interface UnionType<P extends BaseUnionPatterns, K extends keyof P> {
(...args: Parameters<P[K]>): UnionInstance<P, K>;
hasInstance: (model: UnionInstance<P, keyof P>) => boolean;
}
export type UnionTypes<P extends BaseUnionPatterns> = {
[K in keyof P]: UnionType<P, K>;
};
export interface BaseUnionPatterns {
[type: string]: (...args: any) => any;
}
事情并不如愿,总归还是没有办法完全实现我想要的效果,最终的成效是:
// 在这段代码里
// Maybe 变量的类型是对的
const Maybe = union("Maybe", {
// patterns 参数的类型约束是对的
Nothing: () => null,
Just: <T>(value: T) => ({ value }),
Other: <T>(arg1: T, rest: T[]) => [arg1].concat(rest),
});
// a 变量的类型是对的
const a = Maybe.Just(1);
// b 变量的类型被成功推断
const b = a.matchWith({
// branches 的类型约束是对的
Nothing: (arg) => "no value", // arg 的类型确实是 null
Just: (arg) => `value: ${arg.value}`, // arg 的类型是 { value: unknown } (问题一)
Other: (arg) => `values: ${arg.join(",")}`, // arg 的类型是 unknown[] (问题二)
});
a.matchWith({
Just: (arg) => `value: ${arg.value}`,
// TypeScript 里指定的 Symbol 没办法作为 key 的类型,所以只能用特殊 key $any
// 来替代 folktale 里的 any Symbol (问题三)
$any: (arg) => `values: ${arg}`,
});
console.log(b);
我们来一个个看这些问题:
- 关于问题一,让人比较不爽,毕竟
Maybe.Just的参数类型是出现过的,只是因为patterns参数里的Just函数是一个泛型函数,而我们在做类型转换的时候又没办法传递类型参数,所以没办法推断出{ value: T }里T的类型; - 关于问题二,其实可以理解的,毕竟整个
a变量的生命周期里从来没提到过Other的 Payload 类型; - 关于问题三,和问题一一样,都是属于语言设施功能上的缺位,大概只能等 TypeScript 支持相关的特性。
后来结合问题一问题二一想,其实这两个问题可以尝试一起解决,我们只要在声明变量 a 的时候给它传递一个更完整的类型信息就可以了,于是又一阵抓耳挠腮之后,终于写出来一个粗糙的方案:
export type UnionV<
T extends UnionTypes<any>,
WrappedValues extends T extends UnionTypes<infer P>
? { [K in keyof P]: ReturnType<P[K]> }
: never
> = UnionInstance<
{ [K in keyof T]: (...args: Parameters<T[K]>) => WrappedValues[K] },
keyof T
>;
const Maybe = union("Maybe", {
Nothing: () => null,
Just: <T>(value: T) => ({ value }),
Others: <T>(con: T, rest: T[]) => [con].concat(rest),
});
type MaybeNumber = UnionV<
typeof Maybe,
{
// 这里填进去 patterns 各个函数的返回值类型
Nothing: null;
Just: { value: number };
Others: number[];
}
>;
const a = Maybe.Just("a") as MaybeNumber;
// 这回 b 的类型被成功推断为 number | null
const b = a.matchWith({
Nothing: (arg) => arg,
Just: (arg) => arg.value, // arg 的类型为 { value: number }
Others: (items) => items.length, // arg 的类型为 number[]
});
console.log(b);
目前这套方案在 patterns 的参数都是具体的类型时,不需要 UnionV 就能够工作得很好;在出现泛型函数时,我只能想到通过 UnionV 来为 tsc 提供额外的类型信息。
那么,我们这算是大功告成了吗?并没有,因为整套方案是真的不够优雅,UnionV 的第二个类型参数实在是让人倒胃口。而且如果你仔细看的话,会发现我上面这段代码里 Maybe.Just 的参数是一个字符串,而 tsc 并没有报错。我知道是因为我用了转型,但是如果我这么写 a: MaybeNumber = ,tsc 会报错说 Type '{ value: unknown; }' is not assignable to type '{ value: number; }'. ,所以……这就是为什么这个方案不够优雅的原因之二了。
目前还想不出来更好的方案,只能再等等了,看看 TypeScript 接下来会不会提供这套方案需要的东西吧。