TypeScript回调函数类型验证失效-锅给了类型推断

前言

我是TypeScript的初学者,在阅读TypeScript官方手册指南的<函数>一章在<this参数在回调函数里>这一小节产生了疑惑

官方的文档提供的实例本身逻辑和运行结果都没有问题,但是我自己做了几个额外的测试,却产生了一些意想不到的问题.

开发环境:
VSCode 1.26.1 TypeScript 3.0.1

官方例子

这个例子用于在回调函数中如何提供this正确的类型判断

定义一个回调接口:

interface UIElement {
    addClickListener(onclick: (this: void, e: Event) => void): void;
}

定义一个回调类,该类提供一个方法当作回调函数:

class Handler {
    info: string;
    onClickGood(this: void, e: Event) {
        // can't use this here because it's of type void!
        console.log('clicked!');
    }
}
let h = new Handler();
uiElement.addClickListener(h.onClickGood);// uiElement在官方例子中就没有定义,我自己利用他提供了一个实现类

由于缺少了一个实现接口的类,所以我自己定义了一个类且实现了UIElement接口,问题也是由这个引起的.

我的完整例子:

interface UIElement {
    addClickListener(onclick: (this: void, e: string) => void): void;
}

class SideBar implements UIElement {
    addClickListener(onclick: (this: void, e: string) => void): void {
        onclick('event');
    }
}

class Handler {
    info: string = 'ok';
    onClickGood(this:void,event:string):void{
        console.log('clicked');
        console.log(event);
    }
}
let h: Handler = new Handler();
let uiElement: SideBar = new SideBar();

uiElement.addClickListener(h.onClickGood)

这个例子是可以运行且没有错误的,我提供了一个SideBar类,唯一的改动是回调函数中Event的类型改为了String.

例子链接:

https://www.tslang.cn/docs/ha...
例子在页面下方的位置

问题

  1. 一个类实现了接口定义的获取回调函数的方法,类实现的方法有可能不校验其内部的参数结构(回调的具体类型)

    interface UIElement {
        addClickListener(onclick: (this: void, e: string) => void): void;
    }
    
    class SideBar implements UIElement {
        addClickListener(onclick){
            onclick('event');
        }
    }

    这样写是没有问题的,即使SideBar中的addClickListener方法没有提供完整的类型验证,但是TypeScript会自动推测出来,证据如下

    class SideBar implements UIElement {
        addClickListener(onclick,hello){
            onclick('event');
        }
    }

    我给这个方法多添加了一个参数,由于UIElement中没有定义,所以这里提示错误了,证明TypeScript的类型推断工作正常,这符合我们的预期,但是接下来他却工作的不正常了

    interface UIElement {
        addClickListener(onclick: (this: void, e: string) => void): void;
    }
    
    class SideBar implements UIElement {
        addClickListener(onclick){
            onclick('event');
        }
    }
    class Handler {
        info: string;
        onClickGood(this:Handler,event:string):void{
            console.log('clicked!');
            console.log(event);
            
        }
    }
    let h:Handler = new Handler();
    let uiElement:SideBar = new SideBar();
    
    uiElement.addClickListener(h.onClickGood)// 没有报错

    在这个例子的最后一句中我们将Handler类的onClickGood方法传递给了SideBaraddClickListener
    请注意,如果按照之前结构匹配的正常运转效果,这里回调函数的接受类型应该是onclick: (this: void, e: string) => void
    但是我们传递过去的类型却是(this:Handler,event:string) => void显然不是正确的,但是却没有报错.

    只有给SideBar强制添加和UIElement接口一样的类型规则情况下才提示类型不匹配

     class SideBar implements UIElement {
      addClickListener(onclick: (this: void, e: string) => void):void{
          onclick('event');
      }
     }
     class Handler {
         info: string;
         onClickGood(this:Handler,event:string):void{
             // can't use this here because it's of type void!
             console.log('clicked!');
             console.log(event);
         }
     }
     let h:Handler = new Handler();
     let uiElement:SideBar = new SideBar();
     
     uiElement.addClickListener(h.onClickGood)// 报错了类型不匹配
  2. 被接口定义的回调函数的返回值实际实现可以和接口定义的类型不一致
    继续使用之前的例子:

     interface UIElement {
         addClickListener(onclick: (this: void, e: string) => void): void;
     }
     
     class SideBar implements UIElement {
         addClickListener(onclick: (this: void, e: string) => void) :void{
             onclick('event');
         }
     }
     class Handler {
         info: string;
         onClickGood(this:void,event:string){
             console.log('clicked!');
             console.log(event);
             return 123;
         }
     }
     let h:Handler = new Handler();
     let uiElement:SideBar = new SideBar();
     
     uiElement.addClickListener(h.onClickGood)// 没有报错

    注意这里的Handler中的onClickGood他返回的是number类型,编辑器提示也是number类型,但是却可以通过最后一句的测试.
    即使我们addClickListener要求提供的函数的返回值为void.

    除非给onClickGood也添加返回类型:

    class Handler {
         info: string;
         onClickGood(this:void,event:string):void{
             // can't use this here because it's of type void!
             console.log('clicked!');
             console.log(event);
             return 123;
         }
     }

    这下return的值终于被判定为错误了,但是这么一来addClickListener中制定的回调函数类型规则岂不是被无视了

阅读 7.1k
2 个回答

--strict

看到题主let h: Handler = new Handler()的写法,我估计题主来自Java背景。JavaScript全是自动推断,c++11也有auto关键字,只有Java拖到了今年3月才给了var。右边的类型是确定的,左边还要再多写一个类型声明,麻烦到这种程度的写法也就Java有了。

Typescript希望在严谨和方便之间取得平衡,而不是纠结于数学上的正确,所以允许了一些(微软认为常用的)类型不兼容。如果想尽可能像Java一样严格,可以打开编译器的--strict选项。

类实现的方法有可能不校验其内部的参数结构

class SideBar implements UIElement {
    addClickListener(onclick, hello) {
        onclick('event');
    }
}

这段代码会报错,报的什么错呢?

类型“SideBar”中的属性“addClickListener”不可分配给基类型“UIElement”中的同一属性。
不能将类型“(onclick: any, hello: any) => void”分配给类型“(onclick: (this: void, e: string) => void) => void”。

注意到两个any了吗?这里报错,实际上是参数数量的错误,参数类型检查被跳过了。SideBar中的addClickListener方法没有提供完整的类型签名,所以TypeScript直接不管参数类型了。

如果从严谨的角度来看,很坑爹对吧。所以我们打开--strict。现在addClickListener(onclick)也会报错了:

参数“onclick”隐式具有“any”类型。

如果说“接口都定义好了,参数还不能自动推断出来吗?”,确实是这样的,毕竟Java的lambda已经做到了。但总之TypeScript现在还不支持……微软:你来咬我呀~

被接口定义的回调函数的返回值实际实现可以和接口定义的类型不一致

It's a feature, not a bug. 让我们考虑

let foo: () => void;
foo = () => 1;

这是合法的。对Java来说不可理喻对不对。然而

TypeScript里的类型兼容性是基于结构子类型的。 结构类型是一种只使用其成员来描述类型的方式。 它正好与名义(nominal)类型形成对比。 (类型兼容性)

对于函数返回值,

类型系统强制源函数的返回值类型必须是目标函数返回值类型的子类型 (来源同上)

上面的定义太深奥了,讲人话:可以多给,不能少给() => void是什么意思?你别指望我会返回什么东西,但我到底会返回什么,不关你事。

为什么要这样设计?考虑

let realData = { username: 'liqi0816', avatar: 'liqi0816.jpg', page: 'liqi0816.html' };
let mock = true;

function foo(): { username: string } {
    if (mock) {
        return { username: 'test'};
    }
    else {
        return realData;
    }
}

你可以指望我返回的东西有username属性,但我到底会返回什么,不关你事。这样很方便对不对?

所以回到题主的问题

addClickListener(onclick: (this: void, e: string) => void): void

人话:我不需要回调函数有返回值,就算有,我也保证不用,所以你爱返回啥返回啥。

放置了几天无人回答,有可能写的太难懂了,自己又查查文档捣鼓了几下,出现了一些思路

问题研究

我们再次使用一下之前我提供的完整的例子:

interface UIElement {
    addClickListener(onclick: (this: void, e: string) => void): void;
}

class SideBar implements UIElement {
    addClickListener(onclick: (this: void, e: string) => void): void {
        onclick('event');
    }
}

class Handler {
    info: string = 'ok';
    onClickGood(this:void,event:string):void{
        console.log('clicked');
        console.log(event);
    }
}
let h: Handler = new Handler();
let uiElement: SideBar = new SideBar();

uiElement.addClickListener(h.onClickGood)

如果不严格的说这实际上就是一个观察着模式,我这里提供一分同样功能的JS版本(没有模拟继承):

function SideBar(){

}
SideBar.prototype.addClickListener = function (callback){
    callback('event');
}

function Handler(){
    this.info = 'ok';
}
Handler.prototype.onClickGood = function (event){
    console.log(event);
}

let handler = new Handler();
let sidebar = new SideBar();

sidebar.addClickListener(handler.onClickGood);

运行输出:

event

此时在如果我们打印onClickGood内部的this输出的肯定是全局对象(没有开启严格模式)

但是一般在使用观察者模式的时候,我们一般传入的只是一个函数而已,不会去传入一个对象的方法,如下:

window.addEventListener('click',function (event){
    console.log(event)
})

这个才是回调函数,回调函数没有状态this指向的就是全局.

原来的问题是如何在TypeScript中获取正确的this类型,而解决的方法就是显式的指定this的类型为void.

interface UIElement {
    addClickListener(onclick: (this: void, e: string) => void): void;
}
class Handler {
    info: string = 'ok';
    onClickGood (this:void,event:string) :void {
        console.log(event);
    }
}

上面onClickGood方法和UIElement接口定义的addClickListener要求传入的函数格式一致,然后你的this终于获得了正确的类型指向.

但是可笑在TypeScript是有类型推断的,这一点在官方手册指南的接口一章有提及,我们只提供了UIElement却没有提供实现类,现在我们提供一个实现类:

class SideBar implements UIElement {
    addClickListener(onclick: (this: void, e: string) => void): void {
        onclick('event');
    }
}

但是如果有正确的类型推断,我们可以省略SideBar,addClickListener方法中的类型约束,但是去掉的结果就是该方法失去了类型检查功能:

interface UIElement {
    addClickListener(onclick: (this: void, e: string) => void): void;
}
class SideBar implements UIElement {
    addClickListener(onclick) {
        onclick('event');
    }
}
class Handler {
    info: string = 'ok';
    onClickGood (this:Handler,event:string) :void {
        console.log(event);
    }
}

这是可以运行的但是onClickGood可不符合UIElementaddClickListener接受函数定义的类型.

实际上不仅仅是函数,对于一个普通的类型来说工作的也有点不正常:

interface UIElement {
    addClickListener(onclick:string): void;
}

class SideBar implements UIElement {
    addClickListener(onclick) {
        onclick('event');
    }
}

class Handler {
    info: string = 'ok';
    onClickGood (this:Handler,event:string) :void {
        console.log(event);
    }
}

这个例子中UIElement中的addClickListener接受类型直接改成了string这个例子依然可以通过检查,当然他是不可以运行的.

为什么

进过一番折腾后,我找到了一个可以合理解释这一切的原因.

首先:

  1. 类型推断工作只能限制一层

    interface UIElement {
        addClickListener(onclick:string): void;
    }
    
    class SideBar implements UIElement {
        addClickListener(onclick:number) {
            
        }
    }

    这个例子中实现类工作类型为number但是接口规定为string,类型不对报错了.

    但是如果我们在提供一个类继承SideBar:

    interface UIElement {
        addClickListener(onclick:string): void;
    }
    
    class SideBar implements UIElement {
        addClickListener(onclick) {
            
        }
    }
    
    class Menu extends SideBar {
        addClickListener(onclick:number){
    
        }
    }

    Menu即使改成了number类型也没有问题.

  2. 类型推断下的参数实际类型是any
    实际上类型推断约束是正确的不允许你修改类型,但是如果你不指定类型默认的就是any它输入任何类型的子类

    而由于上一级类型以及是any了,后续的类型就可以指定任何类型了

结果

类型推断在函数接口上工作的很好,对于一般的接口而言工作的不是很理想.

例子完美情况下的类型推断:

interface SearchFunc {
    (source: string, subString: string): boolean;
}

let mySearch: SearchFunc;
mySearch = function (src, sub) {
    let result = src.search(sub);
    return result > -1;
}

所以对于多重继承来说类型推断工作的不是很好,我们只能将类型写全,不过对于回调函数我们完全可以定义一个函数接口,来简化代码量:

interface EventCallback {
    (this: void, e: string):void;
}

interface UIElement {
    addClickListener(onclick:EventCallback): void;
}

class SideBar implements UIElement {
    addClickListener(onclick:EventCallback): void {
        onclick('event');
    }
}

class Handler {
    info: string = 'ok';
    onClickGood(this: void, event: string): void {
        console.log('clicked');
        console.log(event);
    }
}
let h: Handler = new Handler();
let uiElement: SideBar = new SideBar();

uiElement.addClickListener(h.onClickGood)
推荐问题