preface

Typescript extends the syntax and semantics of typing to javascript, allowing us to define types for variables, functions, etc., and then check them at compile time. This allows us to detect type mismatches ahead of time and indicate available property methods at development time.

What’s more, typescript doesn’t change its syntax like Coffeescript did. It’s a superset of javascript that just extends its types.

These advantages make typescript quickly popular. If you don’t know typescript, it can be hard to get an offer.

There are plenty of typescript tutorials out there, but none of them analyze the implementation from a compilation standpoint. This article doesn’t cover the basics of typescript, but instead implements a typescript Type Checker to help you understand what type checking actually does. Once you understand how type checking works, learning typescript may not be that difficult.

Thought analysis

Typescript compiler and Babel

Typescript Compiler is a translator that converts typescript syntax into the target javascript for ES2015, ES5, and ES3, and does type checking along the way.

Babel is also a translator that converts es Next, typescript, flow, and other syntaxes into JS supported by the target environment.

Babel also compiles typescript? Yes, Babel 7 will compile typescript code after a year of collaboration between the typescript team and the Babel team.

As we know, the Babel compilation process consists of three steps: parse, Transform, and generate.

The parse stage was responsible for compiling source code into AST, the Transform stage was responsible for adding, deleting and modifying AST, and the generate stage was responsible for printing AST into object code and generating sorucemap.

Babel compiles typescript code, but it can be parsed. it does not do type checking, which can be done based on the AST produced by Babel Parse.

What does type checking do

We often use TSC for type checking. Have you ever wondered what type checking does?

What is a type

A type represents what is stored in a variable, that is, how much memory it occupies and what can be done with it. For example, number and Boolean allocate different bytes of memory, and Date and String can call different methods. That’s what type does. It represents the possibility of how much you can put in the block and what you might do to it.

Dynamic typing means that the type is determined at run time, whereas static typing means that the type information of a variable is known at compile time, so that it knows what operations are legal and illegal for it, and what variables can be assigned to it.

Static typing preserves type information in code, which may be explicitly declared or automatically derived. If you want to do a large project, without static typing to constrain and review the code in advance, it is too buggy and difficult to maintain. That’s why typescript came out and typescript became more popular as front-end projects became more complex.

How to check types

Now that we know what a type is, why do we do static type checking, but how?

Checking the type is checking the contents of the variable, and understanding the code requires parsing the code into the AST, so checking the type becomes checking the AST structure.

For example, if a variable is declared as number, assigning it a string is a type error.

To make things more complicated, if the type is generic, that is, if it has type parameters, then you need to pass in specific parameters to determine the type, and then compare the type with the actual AST.

Typescript also supports advanced typing, meaning that types can perform various operations that require passing in type parameters to figure out which type to compare to the AST.

Let’s write code to implement it:

Code implementation

Implement type checking for simple types

Type checking of assignment statements

For example, the code that declares a string value, but assigns the value to number, obviously has a type error. How can we check it?

let name: string;

name = 111;
Copy the code

First we parse this code into an AST using Babel:

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

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

const ast = parser.parse(sourceCode, {
    plugins: ['typescript']});Copy the code

Use Babel Parser to parse and enable typescript syntax plug-ins.

You can use astexplerer.net to view its AST:

Implement type checking

We need to check whether the AssignmentExpression matches the left and right types.

On the right is a NumericLiteral, which is easy to get the type, and on the left is a reference, which needs to get its declared type from scope before we can do the type comparison.

Babel provides an API for scope to look up type bindings in a scope and get the type of the declaration via getTypeAnnotation

 AssignmentExpression(path, state) {
    const leftBinding = path.scope.getBinding(path.get('left'));
    const leftType = leftBinding.path.get('id').getTypeAnnotation();// The type of the value declared on the left
}
Copy the code

And the type that’s returned is an object of TSTypeAnnotation, and we need to do something to convert it to a type string

Encapsulates a method that passes in an object of type and returns a string of type number, string, and so on

function resolveType(targetType) {
    const tsTypeAnnotationMap = {
        'TSStringKeyword': 'string'
    }
    switch (targetType.type) {
        case 'TSTypeAnnotation':
            return tsTypeAnnotationMap[targetType.typeAnnotation.type];
        case 'NumberTypeAnnotation': 
            return 'number'; }}Copy the code

Now that we have the left and right types, we can easily compare them to see if the types match:

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: Type mismatch}}Copy the code
Error print optimization

How do I print the error message? You can use @babel/code-frame, which allows you to print highlighted code for a fragment.

path.get('right').buildCodeFrameError(`${rightType} can not assign to ${leftType}`.Error)
Copy the code

The effect is as follows:

This Error stack is ugly, so let’s get rid of it and set error.stackTracelimit to 0

Error.stackTraceLimit = 0;
path.get('right').buildCodeFrameError(`${rightType} can not assign to ${leftType}`.Error));
Copy the code

But I’m going to have to change it back, which is:

const tmp = Error.stackTraceLimit;
Error.stackTraceLimit = 0;
console.log(path.get('right').buildCodeFrameError(`${rightType} can not assign to ${leftType}`.Error));
Error.stackTraceLimit = tmp;
Copy the code

Here’s another run:

Much better!

Error collecting

There is another problem, right now we are reporting type errors, but we want to collect them when we encounter type errors and report them collectively.

How do you do that? Where are the mistakes?

File objects are available in the Babel plugin, and the set and GET methods are used to access global information. You can get file objects before and after the plug-in call, in the Pre and POST phases (more on this in the Babel Tutorial).

So we can do this:

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'));
}
Copy the code

In this way, errors can be collected during the process, and finally unified print:

Thus, we implement simple type checking for assignment statements.

Type checking for function calls

The assignment statement check is relatively simple, let’s take it a step further, to implement the function call parameter type check

function add(a: number, b: number) :number{
    return a + b;
}
add(1.'2');
Copy the code

What we want to check here is whether the parameters of the function call statement CallExpression are consistent with the ones it declares.

CallExpression consists of callee and arguments. We need to search for function declarations from scope according to Callee, and then compare the types of arguments with the types of params used for function declarations. This enables type checking of function call parameters.

pre(file) {
    file.set('errors'[]); },visitor: {
    CallExpression(path, state) {
        const errors = state.file.get('errors');
        // The type of the call parameter
        const argumentsTypes = path.get('arguments').map(item= > {
            return resolveType(item.getTypeAnnotation());
        });
        const calleeName = path.get('callee').toString();
        // Find the function declaration according to callee
        const functionDeclarePath = path.scope.getBinding(calleeName).path;
        // The type of argument to get the declaration
        const declareParamsTypes = functionDeclarePath.get('params').map(item= > {
            return resolveType(item.getTypeAnnotation());
        })

        argumentsTypes.forEach((item, index) = > {
            if(item ! == declareParamsTypes[index]) {// The type is not consistent}}); }},post(file) {
    console.log(file.get('errors'));
}
Copy the code

Run it and it looks like this:

We implemented type checking for function call parameters! In fact, the idea is quite clear. Check other AST for similar ideas.

Implement type checking with generics

What generics are, in fact, are type parameters that allow the type to be dynamically determined based on the parameters passed in, making the type definition more flexible.

For example, this code:

function add<T> (a: T, b: T) {
    return a + b;
}
add<number>(1.'2');
Copy the code

How do you do type checking?

This is also the type check of the function call statement, which we implemented above. The difference is that there is only one more parameter, so we can fetch the type parameter and pass it.

CallExpression(path, state) {
    const realTypes = path.node.typeParameters.params.map(item= > {// Get the value of the type argument, i.e. the real type
        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);
    })// Assign the value of the type parameter to the generic parameter of the function declaration

    argumentsTypes.forEach((item, index) = > { // Select a specific type for comparison
        if(item ! == declareParamsTypes[index]) {// Error reported, type inconsistent}}); }Copy the code

An additional step in the process of determining the specific type of a generic parameter.

Execute to see the effect:

We have successfully supported type checking for function call statements with generics!

Implement type checking for function call statements with advanced types

Typescript supports advanced typing, which means that you can perform various operations on type parameters and then return the final type

type Res<Param> = Param extends 1 ? number : string;
function add<T> (a: T, b: T) {
    return a + b;
}
add<Res<1> > (1.'2');
Copy the code

For example, in this code, Res is an advanced type that returns the new type after processing the type parameter Param passed in.

The type checking of the function call statement is a bit more complicated than passing the specific type of the generic parameter. You need to find the specific type first, then pass the parameter, and then compare the type of the parameter.

So how do we evaluate the advanced type of Res?

Let’s look at the AST of type Res:

It has a typeParameters section, and a concrete typeAnnotation section. Param extends 1? number : string; It is a condition statement with Params and 1 corresponding to checkType and extendsType, and Number and String corresponding to trueType and falseType.

We only need to check whether the incoming Param is 1 to determine whether the specific type is trueType or falseType.

The logic of passing a type parameter is the same as above. Let’s look at the logic of passing a value by type parameter:

function typeEval(node, params) {
    let checkType;
    if(node.checkType.type === 'TSTypeReference') {
        checkType = params[node.checkType.typeName.name];// If the parameter is generic, the value is taken from the parameter passed in
    } else {
        checkType = resolveType(node.checkType); // Otherwise take a literal argument
    }
    const extendsType = resolveType(node.extendsType);
    if (checkType === extendsType || checkType instanceof extendsType) { // If extends logic holds
        return resolveType(node.trueType);
    } else {
        returnresolveType(node.falseType); }}Copy the code

In this way, we can figure out the advanced type of this Res and the final type that we get when we pass in Params of 1.

Once you have the final type, it is the same as type checking for function calls passed in directly to the concrete type. (We implemented that above)

Execute it and the result is as follows:

The complete code is as follows (it’s a bit long, so you can skip ahead) :

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 {
        returnresolveType(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]; 
                returnobj; }, {});return typeEval(typeAlias.body, params);
        case 'TSLiteralType':
            returntargetType.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;

Copy the code

Just like that, we implement typescript advanced types!

conclusion

A type represents the content of a variable and what can be done to it. Static typing allows checking to be done at compile time. As front-end projects get heavier, there is a growing need for statically typed languages like typescript.

Type checking is to compare the AST to determine whether the declared and the actual are consistent:

  • Simple types are direct comparisons, like if else
  • If you have a generic type, you need to pass in the type argument to determine the type, and then, by contrast, you can wrap the if else function call
  • Type checking for generics with advanced types has an additional type evaluation process, equivalent to determining if else after multiple function calls

Implementing a full typescript Type Cheker is complicated, or the typescript Checker section would not have tens of thousands of lines of code. But the idea is not that difficult, and it is possible to implement a complete Type checker by following the idea in our article.

If you don’t understand the Babel plugin and API, you can read more about it in my forthcoming book, “Babel Plugin Secrets”. If you have Babel, you have static analysis, linter, Type Checker, etc.)