基于 babel 手写 ts type checker

前言

typescript 给 javascript 扩展了类型的语法和语义,让咱们能够给变量、函数等定义类型,而后编译期间检查,这样可以提早发现类型不匹配的错误,还可以在开发时提示可用的属性方法。javascript

并且,typescript 并不像当年的 coffeescript 同样改变了语法,它是 javascript 的一个超集,只作了类型的扩展。前端

这些优势使得 typescript 迅速的火了起来。如今前端面试若是你不会 typescript,那么可能很难拿到 offer。java

市面上关于 typescript 的教程文不少了,可是没有一篇去从编译原理的角度分析它的实现的。本文不会讲 typescript 的基础,而是会实现一个 typescript type checker,帮你理解类型检查究竟作了什么。理解了类型检查的实现思路,再去学 typescript,或许就没那么难了。node

image.png

思路分析

typescript compiler 与 babel

typescript compiler 是一个 转译器,负责把 typescript 的语法转成 es201五、es五、es3 的目标 javascript,而且过程当中会作类型检查。面试

babel 也是一个转译器,能够把 es next、typescript、flow 等语法转成目标环境支持的 js。typescript

babel 也能够编译 typescript? 对的,babel 7 之后就能够编译 typescript 代码,这仍是 typescript 团队和 babel 团队合做一年的成果。api

咱们知道,babel 编译流程分为 3 个步骤:parse、transform、generate。babel

parse 阶段负责编译源码成 AST,transform 阶段对 AST 进行增删改,generate 阶段打印 AST 成目标代码并生成 sorucemap。markdown

babel 能够编译 typescript 代码只是可以 parse,并不会作类型检查,咱们彻底能够基于 babel parse 出的 AST 来实现一下类型检查。app

类型检查要作什么

咱们常常用 tsc 来作类型检查,有没有想过,类型检查具体作了什么?

什么是类型

类型表明了变量存储的内容,也就是规定了这块内容占据多大的内存空间,能够对它作什么操做。好比 number 和 boolean 就会分配不一样字节数的内存,Date 和 String 能够调用的方法也不一样。这就是类型的做用。它表明了一种可能性,你能够在这块内存放多少内容,可能对它进行什么操做。

动态类型是指类型是在运行时肯定的,而静态类型是指编译期间就知道了变量的类型信息,有了类型信息天然就知道了对它而言什么操做是合法的,什么操做是不合法的,什么变量可以赋值给他。

静态类型会在代码中保留类型信息,这个类型信息多是显式声明的,也多是自动推导出来的。想作一个大的项目,没有静态类型来约束和提早检查代码的话,太容易出 bug 了,会很难维护。这也是随着前端项目逐渐变得复杂,出现了 typescript 以及 typescript 愈来愈火的缘由。

如何检查类型

咱们知道了什么是类型,为何要作静态的类型检查,那么怎么检查呢?

检查类型就是检查变量的内容,而理解代码的话须要把代码 parse 成 AST,因此类型检查也就变成了对 AST 结构的检查。

好比一个变量声明为了 number,那么给它赋值的是一个 string 就是有类型错误。

再复杂一点,若是类型有泛型,也就是有类型参数,那么须要传入具体的参数来肯定类型,肯定了类型以后再去和实际的 AST 对比。

typescript 还支持高级类型,也就是类型能够作各类运算,这种就须要传入类型参数求出具体的类型再去和 AST 对比。

咱们来写代码实现一下:

代码实现

实现简单类型的类型检查

赋值语句的类型检查

好比这样一段代码,声明的值是一个 string,可是赋值为了 number,明显是有类型错误的,咱们怎么检查出它的错误的。

let name: string;

name = 111;
复制代码

首先咱们使用 babel 把这段代码 parse 成 AST:

const  parser = require('@babel/parser');

const sourceCode = ` let name: string; name = 111; `;

const ast = parser.parse(sourceCode, {
    plugins: ['typescript']
});
复制代码

使用 babel parser 来 parse,启用 typescript 语法插件。

可使用 astexplerer.net 来查看它的 AST:

image.png

实现类型检查

咱们须要检查的是这个赋值语句 AssignmentExpression,左右两边的类型是否匹配。

右边是一个数字字面量 NumericLiteral,很容易拿到类型,而左边则是一个引用,要从做用域中拿到它声明的类型,以后才能作类型对比。

babel 提供了 scope 的 api 能够用于查找做用域中的类型声明(binding),而且还能够经过 getTypeAnnotation 得到声明时的类型

AssignmentExpression(path, state) {
    const leftBinding = path.scope.getBinding(path.get('left'));
    const leftType = leftBinding.path.get('id').getTypeAnnotation();// 左边的值声明的类型
}
复制代码

这个返回的类型是 TSTypeAnnotation 的一个对象,咱们须要作下处理,转为类型字符串

封装一个方法,传入类型对象,返回 number、string 等类型字符串

function resolveType(targetType) {
    const tsTypeAnnotationMap = {
        'TSStringKeyword': 'string'
    }
    switch (targetType.type) {
        case 'TSTypeAnnotation':
            return tsTypeAnnotationMap[targetType.typeAnnotation.type];
        case 'NumberTypeAnnotation': 
            return 'number';
    }
}
复制代码

这样咱们拿到了左右两边的类型,接下来就简单了,对比下就知道了类型是否匹配:

AssignmentExpression(path, state) {
    const rightType = resolveType(path.get('right').getTypeAnnotation());
    const leftBinding = path.scope.getBinding(path.get('left'));
    const leftType = resolveType(leftBinding.path.get('id').getTypeAnnotation());
    if (leftType !== rightType ) {
        // error: 类型不匹配
    }
}
复制代码
错误打印优化

报错信息怎么打印呢?可使用 @babel/code-frame,它支持打印某一片断的高亮代码。

path.get('right').buildCodeFrameError(`${rightType} can not assign to ${leftType}`, Error)
复制代码

效果以下:

image.png

这个错误堆栈也太丑了,咱们把它去掉,设置 Error.stackTraceLimit 为 0 就好了

Error.stackTraceLimit = 0;
path.get('right').buildCodeFrameError(`${rightType} can not assign to ${leftType}`, Error));
复制代码

可是这里改了以后还要改回来,也就是:

const tmp = Error.stackTraceLimit;
Error.stackTraceLimit = 0;
console.log(path.get('right').buildCodeFrameError(`${rightType} can not assign to ${leftType}`, Error));
Error.stackTraceLimit = tmp;
复制代码

再来跑一下:

好看多了!

错误收集

还有一个问题,如今是遇到类型错误就报错,但咱们但愿是在遇到类型错误时收集起来,最后统一报错。

怎么实现呢?错误放在哪?

babel 插件中能够拿到 file 对象,有 set 和 get 方法用来存取一些全局的信息。能够在插件调用先后,也就是 pre 和 post 阶段拿到 file 对象(这些在掘金小册《babel 插件通关秘籍》中会细讲)。

因此咱们能够这样作:

pre(file) {
    file.set('errors', []);
},
visitor: {
    AssignmentExpression(path, state) {
        const errors = state.file.get('errors');

        const rightType = resolveType(path.get('right').getTypeAnnotation());
        const leftBinding = path.scope.getBinding(path.get('left'));
        const leftType = resolveType(leftBinding.path.get('id').getTypeAnnotation());
        if (leftType !== rightType ) {
            const tmp = Error.stackTraceLimit;
            Error.stackTraceLimit = 0;
            errors.push(path.get('right').buildCodeFrameError(`${rightType} can not assign to ${leftType}`, Error));
            Error.stackTraceLimit = tmp;
        } 
    }
},
post(file) {
    console.log(file.get('errors'));
}
复制代码

这样就能够作到过程当中收集错误,最后统一打印:

这样,咱们就实现了简单的赋值语句的类型检查。

函数调用的类型检查

赋值语句的检查比较简单,咱们来进阶一下,实现函数调用参数的类型检查

function add(a: number, b: number): number{
    return a + b;
}
add(1, '2');
复制代码

这里咱们要检查的就是函数调用语句 CallExpression 的参数和它声明的是否一致。

CallExpression 有 callee 和 arguments 两部分,咱们须要根据 callee 从做用域中查找函数声明,而后再把 arguments 的类型和函数声明语句的 params 的类型进行逐一对比,这样就实现了函数调用参数的类型检查。

pre(file) {
    file.set('errors', []);
},
visitor: {
    CallExpression(path, state) {
        const errors = state.file.get('errors');
        // 调用参数的类型
        const argumentsTypes = path.get('arguments').map(item => {
            return resolveType(item.getTypeAnnotation());
        });
        const calleeName = path.get('callee').toString();
        // 根据 callee 查找函数声明
        const functionDeclarePath = path.scope.getBinding(calleeName).path;
        // 拿到声明时参数的类型
        const declareParamsTypes = functionDeclarePath.get('params').map(item => {
            return resolveType(item.getTypeAnnotation());
        })

        argumentsTypes.forEach((item, index) => {
            if (item !== declareParamsTypes[index]) {
                // 类型不一致,报错
            }
        });
    }
},
post(file) {
    console.log(file.get('errors'));
}
复制代码

运行一下,效果以下:

咱们实现了函数调用参数的类型检查!实际上思路仍是挺清晰的,检查别的 AST 也是相似的思路。

实现带泛型的类型检查

泛型是什么,其实就是类型参数,使得类型能够根据传入的参数动态肯定,类型定义更加灵活。

好比这样一段代码:

function add<T>(a: T, b: T) {
    return a + b;
}
add<number>(1, '2');
复制代码

怎么作类型检查呢?

这仍是函数调用语句的类型检查,咱们上面实现过了,区别不过是多了个参数,那么咱们取出类型参数来传过去就好了。

CallExpression(path, state) {
    const realTypes = path.node.typeParameters.params.map(item => {// 先拿到类型参数的值,也就是真实类型
        return resolveType(item);
    });
    const argumentsTypes = path.get('arguments').map(item => {
        return resolveType(item.getTypeAnnotation());
    });
    const calleeName = path.get('callee').toString();
    const functionDeclarePath = path.scope.getBinding(calleeName).path;
    const realTypeMap = {};
    functionDeclarePath.node.typeParameters.params.map((item, index) => {
        realTypeMap[item.name] = realTypes[index];
    });
    const declareParamsTypes = functionDeclarePath.get('params').map(item => {
        return resolveType(item.getTypeAnnotation(), realTypeMap);
    })// 把类型参数的值赋值给函数声明语句的泛型参数

    argumentsTypes.forEach((item, index) => { // 作类型检查的时候取具体的类型来对比
        if (item !== declareParamsTypes[index]) {
            // 报错,类型不一致
        }
    });
}
复制代码

多了一步肯定泛型参数的具体类型的过程。

执行看下效果:

咱们成功支持了带泛型的函数调用语句的类型检查!

实现带高级类型的函数调用语句的类型检查

typescript 支持高级类型,也就是支持对类型参数作各类运算而后返回最终类型

type Res<Param> = Param extends 1 ? number : string;
function add<T>(a: T, b: T) {
    return a + b;
}
add<Res<1>>(1, '2');
复制代码

好比这段代码中,Res 就是一个高级类型,对传入的类型参数 Param 进行处理以后返回新类型。

这个函数调用语句的类型检查,比泛型参数传具体的类型又复杂了一些,须要先求出具体的类型,而后再传入参数,以后再去对比参数的类型。

那么这个 Res 的高级类型怎么求值呢?

咱们来看一下这个 Res 类型的 AST:

image.png

它有类型参数部分(typeParameters),和具体的类型计算逻辑部分(typeAnnotation),右边的 Param extends 1 ? number : string; 是一个 condition 语句,有 Params 和 1 分别对应 checkType、extendsType,number 和 string 则分别对应 trueType、falseType。

咱们只须要对传入的 Param 判断下是不是 1,就能够求出具体的类型是 trueType 仍是 falseType。

具体类型传参的逻辑和上面同样,就不赘述了,咱们看一下根据类型参数来值的逻辑:

function typeEval(node, params) {
    let checkType;
    if(node.checkType.type === 'TSTypeReference') {
        checkType = params[node.checkType.typeName.name];// 若是参数是泛型,则从传入的参数取值
    } else {
        checkType = resolveType(node.checkType); // 不然直接取字面量参数
    }
    const extendsType = resolveType(node.extendsType);
    if (checkType === extendsType || checkType instanceof extendsType) { // 若是 extends 逻辑成立
        return resolveType(node.trueType);
    } else {
        return resolveType(node.falseType);
    }
}
复制代码

这样,咱们就能够求出这个 Res 的高级类型当传入 Params 为 1 时求出的最终类型。

有了最终类型以后,就和直接传入具体类型的函数调用的类型检查同样了。(上面咱们实现过)

执行一下,效果以下:

image.png

完整代码以下(有些长,能够先跳过日后看):

const { declare } = require('@babel/helper-plugin-utils');

function typeEval(node, params) {
    let checkType;
    if(node.checkType.type === 'TSTypeReference') {
        checkType = params[node.checkType.typeName.name];
    } else {
        checkType = resolveType(node.checkType);
    }
    const extendsType = resolveType(node.extendsType);
    if (checkType === extendsType || checkType instanceof extendsType) {
        return resolveType(node.trueType);
    } else {
        return resolveType(node.falseType);
    }
}

function resolveType(targetType, referenceTypesMap = {}, scope) {
    const tsTypeAnnotationMap = {
        TSStringKeyword: 'string',
        TSNumberKeyword: 'number'
    }
    switch (targetType.type) {
        case 'TSTypeAnnotation':
            if (targetType.typeAnnotation.type === 'TSTypeReference') {
                return referenceTypesMap[targetType.typeAnnotation.typeName.name]
            }
            return tsTypeAnnotationMap[targetType.typeAnnotation.type];
        case 'NumberTypeAnnotation': 
            return 'number';
        case 'StringTypeAnnotation':
            return 'string';
        case 'TSNumberKeyword':
            return 'number';
        case 'TSTypeReference':
            const typeAlias = scope.getData(targetType.typeName.name);
            const paramTypes = targetType.typeParameters.params.map(item => {
                return resolveType(item);
            });
            const params = typeAlias.paramNames.reduce((obj, name, index) => {
                obj[name] = paramTypes[index]; 
                return obj;
            },{});
            return typeEval(typeAlias.body, params);
        case 'TSLiteralType':
            return targetType.literal.value;
    }
}

function noStackTraceWrapper(cb) {
    const tmp = Error.stackTraceLimit;
    Error.stackTraceLimit = 0;
    cb && cb(Error);
    Error.stackTraceLimit = tmp;
}

const noFuncAssignLint = declare((api, options, dirname) => {
    api.assertVersion(7);

    return {
        pre(file) {
            file.set('errors', []);
        },
        visitor: {
            TSTypeAliasDeclaration(path) {
                path.scope.setData(path.get('id').toString(), {
                    paramNames: path.node.typeParameters.params.map(item => {
                        return item.name;
                    }),
                    body: path.getTypeAnnotation()
                });
                path.scope.setData(path.get('params'))
            },
            CallExpression(path, state) {
                const errors = state.file.get('errors');
                const realTypes = path.node.typeParameters.params.map(item => {
                    return resolveType(item, {}, path.scope);
                });
                const argumentsTypes = path.get('arguments').map(item => {
                    return resolveType(item.getTypeAnnotation());
                });
                const calleeName = path.get('callee').toString();
                const functionDeclarePath = path.scope.getBinding(calleeName).path;
                const realTypeMap = {};
                functionDeclarePath.node.typeParameters.params.map((item, index) => {
                    realTypeMap[item.name] = realTypes[index];
                });
                const declareParamsTypes = functionDeclarePath.get('params').map(item => {
                    return resolveType(item.getTypeAnnotation(), realTypeMap);
                })

                argumentsTypes.forEach((item, index) => {
                    if (item !== declareParamsTypes[index]) {
                        noStackTraceWrapper(Error => {
                            errors.push(path.get('arguments.' + index ).buildCodeFrameError(`${item} can not assign to ${declareParamsTypes[index]}`,Error));
                        });
                    }
                });
            }
        },
        post(file) {
            console.log(file.get('errors'));
        }
    }
});

module.exports = noFuncAssignLint;

复制代码

就这样,咱们实现了 typescript 高级类型!

总结

类型表明了变量的内容和能对它进行的操做,静态类型让检查能够在编译期间作,随着前端项目愈来愈重,愈来愈须要 typescript 这类静态类型语言。

类型检查就是作 AST 的对比,判断声明的和实际的是否一致:

  • 简单类型就直接对比,至关于 if else
  • 带泛型的要先把类型参数传递过去才能肯定类型,以后对比,至关于函数调用包裹 if else
  • 带高级类型的泛型的类型检查,多了一个对类型求值的过程,至关于多级函数调用以后再判断 if else

实现一个完整的 typescript type cheker 仍是很复杂的,否则 typescript checker 部分的代码也不至于好几万行了。可是思路其实没有那么难,按照咱们文中的思路来,是能够实现一个完整的 type checker 的。

(关于 babel 插件和 api 的部分,若是看不懂,能够在我即将上线的小册《babel 插件通关秘籍》中来详细了解。掌握了 babel,也就掌握了静态分析的能力,linter、type checker 这些顺带也能更深刻的掌握。)

相关文章
相关标签/搜索