网站搜索

typescript - 为接受任意纯一元函数作为可选参数的高阶函数进行类型定义


我想编写一个高阶函数,它接收一个可选的回调函数,该回调函数可能会也可能不会改变其输入的类型。

我首先尝试了以下内容,没有选项:

type Fn<T, U> = (x: T) => U;
type apply = <T, U>(x: T, f: Fn<T, U>) => U;
const apply:apply = (x, f) => f(x);

看起来它运行得很好:

const id = <T>(x: T): T => x;
apply(1, id);
apply(1, String);

但是当我尝试使用任何东西作为默认参数时,我会得到一个类型错误

type apply = <T, U>(x: T, f?: Fn<T, U>) => U;
const apply:apply = (x, f=id) => f(x);
//                      ----
// Type '<T>(x: T) => T' is not assignable to type 'Fn<T, U>'.
//  Type 'T' is not assignable to type 'U'.
//   'U' could be instantiated with an arbitrary type which could be unrelated to 'T'
const apply:apply = (x, f = (x:number) => x * 2) => f(x);
//                      -----------------------
// Type '(x: number) => number' is not assignable to type 'Fn<T, U>'.
//  Types of parameters 'x' and 'x' are incompatible.
//    Type 'T' is not assignable to type 'number'
const apply:apply = (x, f = (x:any):string => x + '') => f(x);
//                      ----------------------------
// Type '(x: any) => string' is not assignable to type 'Fn<T, U>'.
//  Type 'string' is not assignable to type 'U'.
//    'U' could be instantiated with an arbitrary type which could be unrelated to 'string'

我也尝试过不使用默认参数,而是在函数体内处理可选参数

const apply = <T, U>(x: T, f?: Fn<T, U>): U => f ? f(x) : x;
//                                             ------------
// Type 'T | U' is not assignable to type 'U'.
//  'U' could be instantiated with an arbitrary type which could be unrelated to 'T | U'.
//    Type 'T' is not assignable to type 'U'.
//      'U' could be instantiated with an arbitrary type which could be unrelated to 'T'

你能解释一下语言服务器认为这里的问题是什么吗?

我该如何表达这种类型?

答案#1

对于通用函数,调用者指定类型参数,而不是实现者。通常这种指定会通过类型推断自动发生,但从技术上讲,仍然由调用者控制,而不是实现者。

让我们来看一下这个 apply() 的实现:

const apply = <T, U>(x: T, f?: Fn<T, U>): U => f ? f(x) : x;

如果调用者使用两个函数参数调用 apply(),一切正常:

apply(Math.random(), (x: number) => String(x)).toUpperCase();

在这里,编译器将 number 推断为 T,将 string 推断为 U。这等同于调用者写下以下内容:

apply<number, string>(Math.random(), (x: number) => String(x)).toUpperCase(); // okay

如果调用者尝试在指定不兼容的类型参数时传递相同的函数参数,编译器会发出警告:

apply<string, number>(Math.random(), (x: number) => String(x)).toUpperCase(); // error!
// -----------------> ~~~~~~~~~~~~~     ---------------------> ~~~~~~~~~~~
// number is not string                 number has no toUpperCase

所以一切都如它应该的那样。


不过,如果调用方仅用一个函数参数调用 apply(),则可能会发生问题:

apply(Math.random());

在这里,编译器为 T 推断 number,但没有任何可以用来推断 U 的信息。因此推断失败,编译器退回到 unknown 类型。所以这相当于这样做:

apply<number, unknown>(Math.random()); // okay

这仍然没问题,因为将输出视为unknown是安全的……尽管没什么用。但请注意,当调用者手动为U指定其他类型时会发生什么:

apply<number, string>(Math.random()).toUpperCase(); // okay?!
// RUNTIME ERROR!!!

调用者基本上说的是“请返回一个 string”,这是允许的。调用者可以控制 T 和 U 是什么。因此编译器认为确实发生了这种情况。因此,当你尝试调用输出的 toUpperCase() 方法时,没有编译器错误。但在运行时,这会爆炸。


因为实现是错误的。 编译器正在告诉你:

const apply = <T, U>(x: T, f?: Fn<T, U>): U => f ? f(x) : x;
//                                             ------------
// Type 'T | U' is not assignable to type 'U'.
//  'U' could be instantiated with an arbitrary type which could be unrelated to 'T | U'.
//    Type 'T' is not assignable to type 'U'.
//      'U' could be instantiated with an arbitrary type which could be unrelated to 'T'

它的意思是,你声称返回的是类型 U 的值,但实际上返回的是类型 T | U 的值。如果 U 是某种任意类型(例如示例中的 string),并且恰巧与 T(示例中的 number)不一致,那么你就没有履行合同,可能会发生不好的情况(比如当别人调用你的函数时会出现运行时错误)。


那么,能做什么呢?如果你并不真正担心(虽然可能性极小)呼叫者会手动指定U为某个随机类型并期望出现魔法效果,你可以简单地使用类型断言来告诉编译器不必担心可能的问题:

const applyAssert = <T, U = T>(x: T, f?: Fn<T, U>): U => f ? f(x) : x as any as U;

通过 x as any as U 我是在说“是的,我知道你不能确定 x 是 U,但我告诉你它将会是。” 此外,我给 U 设置了一个默认值 T(通过 U=T),以便当编译器无法推断 U 时,它会回退到默认值。 这给你带来了这里可能期望的行为:

applyAssert(Math.random()).toFixed(2); // okay
// const applyAssert: <number, number>(x: number, f?: Fn<number, number> | undefined) => number

但请记住,它无法防护邪恶/困惑的来电者:

applyAssert<number, string>(Math.random()).toUpperCase(); // okay?!

如果你想让 apply() 完全类型安全,那就更复杂了,而且你仍然必须使用类似断言的东西来帮助编译器。也许可以像这样做:

function applyOverload<T>(x: T): T;
function applyOverload<T, U>(x: T, f: Fn<T, U>): U;
function applyOverload(x: any, f?: Function) {
    return f ? f(x) : x;
}

这是一个带有两个不同调用签名的重载函数:一个用于单个参数,一个用于两个参数。如果你用一个参数调用它,则没有 U,只有 T。因此,即使手动调用,也不可能调用错误:

applyOverload(Math.random(), (x: number) => String(x)).toUpperCase(); // okay
applyOverload(Math.random()).toFixed(); // okay
applyOverload<number, string>(Math.random()); // error! expect 2 arguments, but got 1

至于为什么这被称为“类似断言”,重载函数通常不是安全的。实现会被松散地检查。上面,你可以看到实现接受类型为any和Function的弱类型参数,并返回类型为any的值。碰巧我们正确地实现了这个函数,但如果我们在这里做了一些奇怪的事情,编译器不会注意到也不会在意:

function applyBadOverload<T>(x: T): T;
function applyBadOverload<T, U>(x: T, f: Fn<T, U>): U;
function applyBadOverload(x: any, f?: Function) {
    return "oopsie"; // no error
}

所以无论哪种方式,你都需要小心。


代码的 Playground 链接