DEV Community

Jason Barr
Jason Barr

Posted on • Updated on

Create Your Own Programming Language 4: Variables and Types

Wanda has come a long way since we started. We have the ability to call functions and work with basic data types. The reader also works with linked lists like a real Lisp.

Now it's time to add variables to the language.

In this article we'll add 3 new syntactic forms: variable declarations, set expressions, and do blocks.

Variable declarations are exactly what they sound like. Set expressions allow you to change the value of a variable. Do blocks are a list of expressions you can use when you want to fit multiple expressions into a space made for just a single expression.

The Perils of Mutable State

Note that by introducing both variable declarations and a means to modify the value of variables we're opening the door to having mutable state in our programs. Mutable state means values your program relies on to calculate its result that can change throughout the course of the program.

I've decided to allow mutable state in Wanda as a kind of "escape hatch" for when it's really necessary, but the reality is it's actually rare that you really need mutable state. It's even more rare that using mutable state is actually a good idea.

By avoiding mutable state in your programs you go a long way towards making your programs easier to reason about and understand. Just because I'm giving us set expressions in Wanda does not mean it's a good idea to actually use them unless it's absolutely necessary.

Ok, end rant.

Semantic Analysis

We'll also dip our toes into the water of semantic analysis and start working on a proper type checker for Wanda.1

Wanda is a gradually typed language, which means you can use it either like a statically typed language or a dynamically typed one. With type annotations, you turn on the type checker for an extra layer of safety. If you use the language without annotations, you can work with it just like any other dynamically typed language; the type checker won't get in your way.

When we're done, Wanda will have a very expressive type system including records, objects and classes, algebraic data types, and generics.

As always, if you haven't read the previous post on call expressions you should go ahead and do that first. Really, it's ok. I'll wait right here for you.

Ok, ready? Awesome, let's go!

Fixing A Mistake from Last Time

First, we need to undo something we did in the last article. I didn't think through things well enough, and I neglected to consider the impact it would have to wrap the compiled output of a Program node in an immediately-invoked function expression (IIFE).

The problem is when we try to declare variables: declaring a variable inside an IIFE makes the variable local to that function, which means we can never use it again. That's obviously not what we want.

So open src/emitter/Emitter.js and modify the emitProgram method like this:

  emitProgram(node, ns) {
    let code = "";
    for (let n of node.body) {
      code += `${this.emit(n, ns)};\n`;
    }

    return code;
  }
Enter fullscreen mode Exit fullscreen mode

That takes care of the problem.

What We Don't Need To Change Today

The lexer and reader have occupied a large amount of our time in the first few articles in this series, but for today's article we can leave them just as they are.

What We Do Need To Change Today

We'll make some changes to the parser to accommodate the new syntactic forms and to parse type annotations. We'll need to update the emitter to handle the new forms, and we'll also need to implement the guts of the type checker.

The type checker will take the most time and effort to implement, so we'll save it for last.

We'll also update the CLI so the type checker is part of our compilation pipeline and allow multiline input in the REPL. A common convention with do blocks is to put each expression on its own line, which is hard to do when all your input has to be on the same line in the REPL.

Updates To The Parser

The first big update to the parser we're going to make is to change a property on all the AST nodes.

Of Types and Kinds

I've decided that since we're doing type checking in the compiler it makes more sense to rename the type property on AST nodes to kind and save things related to the word "type" for type checking.

In src/parser/ast.js update the node creators like so:

export const AST = {
  Program(exprs) {
    return {
      kind: ASTTypes.Program,
      body: exprs,
      srcloc: exprs[0]?.srcloc ?? SrcLoc.new(0, 0, 0, "none"),
    };
  },
  NumberLiteral(token) {
    return {
      kind: ASTTypes.NumberLiteral,
      value: token.value,
      srcloc: token.srcloc,
    };
  },
  StringLiteral(token) {
    return {
      kind: ASTTypes.StringLiteral,
      value: token.value,
      srcloc: token.srcloc,
    };
  },
  BooleanLiteral(token) {
    return {
      kind: ASTTypes.BooleanLiteral,
      value: token.value,
      srcloc: token.srcloc,
    };
  },
  KeywordLiteral(token) {
    return {
      kind: ASTTypes.KeywordLiteral,
      value: token.value,
      srcloc: token.srcloc,
    };
  },
  NilLiteral(token) {
    return {
      kind: ASTTypes.NilLiteral,
      value: token.value,
      srcloc: token.srcloc,
    };
  },
  Symbol(token) {
    return {
      kind: ASTTypes.Symbol,
      name: token.value,
      srcloc: token.srcloc,
    };
  },
  CallExpression(func, args, srcloc) {
    return {
      kind: ASTTypes.CallExpression,
      func,
      args,
      srcloc,
    };
  },
};
Enter fullscreen mode Exit fullscreen mode

In src/emitter/Emitter.js switch on node.kind instead of node.type in the emit method:

  emit(node = this.program, ns = this.ns) {
    switch (node.kind) {
    // and so on...
Enter fullscreen mode Exit fullscreen mode

Then, also in the emit method, in the default block change the first argument to SyntaxException from node.type to node.kind.

Finally, in src/printer/printAST.js change all references to node.type to node.kind including in the print and printPrimitive methods.

Parsing The New Forms

Variable declarations, set expressions, and do blocks have the following syntax:

(var x 10)
(set! x 15)
(do
  (var a 10)
  (println "About to increment a")
  (+ a 1))
Enter fullscreen mode Exit fullscreen mode

Set expressions use the set! keyword, with the exclamation point as an indicator that this form mutates a value.

You can also use a type annotation with a variable declaration to turn on the type checker:

(var (x : number) 10)
Enter fullscreen mode Exit fullscreen mode

Note that there must be a space both between the variable name and the colon and the colon and the type. That's to prevent the lexer from reading the colon as either part of the variable name or the beginning of a keyword.

First, let's add the AST node types to the node constructors object. In src/parser/ast.js:

export const AST = {
  // existing constructors...
  VariableDeclaration(lhv, expression, srcloc, typeAnnotation = null) {
    return {
      kind: ASTTypes.VariableDeclaration,
      lhv,
      expression,
      srcloc,
      typeAnnotation,
    };
  },
  SetExpression(lhv, expression, srcloc) {
    return {
      kind: ASTTypes.SetExpression,
      lhv,
      expression,
      srcloc,
    };
  },
  DoExpression(body, srcloc) {
    return {
      kind: ASTTypes.DoExpression,
      body,
      srcloc,
    };
  },
};
Enter fullscreen mode Exit fullscreen mode

We're just going to stub a function for parsing type annotations for right now; we'll come back to it when it's time to work on the type checker. In src/parser/parseTypeAnnotation.js add this stub:

export const parseTypeAnnotation = (annotation) => {};
Enter fullscreen mode Exit fullscreen mode

Now we get to add our first non-call expression forms to the parseList function. First, let's write the parse functions for variable declarations, set expressions, and do blocks.

For variable declarations and set expressions, we'll need left hand values (LHVs) and initializer expressions. For do blocks, we'll just need a list of expressions.

I've gone ahead and included everything you'll need in this file to parse type annotations. Note that we're skipping over the colon and converting the tail of the Cons list to a JavaScript array.

In src/parser/parse.js:

const parseVariableDeclaration = (decl) => {
  let [_, lhv, expression] = decl;

  let parsedLhv,
    typeAnnotation = null;
  if (lhv instanceof Cons) {
    // has type annotation
    const realLhv = lhv.get(0);
    // convert to array and get rid of ":" when passing into parseTypeAnnotation
    typeAnnotation = parseTypeAnnotation([...lhv.cdr].slice(1));
    parsedLhv = parseExpr(realLhv);
  } else {
    parsedLhv = parseExpr(lhv);
  }

  const parsedExpression = parseExpr(expression);

  return AST.VariableDeclaration(
    parsedLhv,
    parsedExpression,
    decl.srcloc,
    typeAnnotation
  );
};

const parseSetExpression = (expr) => {
  const [_, lhv, expression] = expr;
  const parsedLhv = parseExpr(lhv);
  const parsedExpression = parseExpr(expression);

  return AST.SetExpression(parsedLhv, parsedExpression, expr.srcloc);
};

const parseDoExpression = (expr) => {
  const [_, ...exprs] = expr;
  let body = [];

  for (let ex of exprs) {
    body.push(parseExpr(ex));
  }

  return AST.DoExpression(body, expr.srcloc);
};
Enter fullscreen mode Exit fullscreen mode

Now, in the parseList function, add the following 3 cases to the switch statement:

  switch (first.value) {
    case "var":
      return parseVariableDeclaration(form);
    case "set!":
      return parseSetExpression(form);
    case "do":
      return parseDoExpression(form);
    // default case...
Enter fullscreen mode Exit fullscreen mode

That's it for the parser until we come back to parse type annotations. We'll also add parsing type aliases, which is super simple.

Changes To The Emitter

Obviously we need to add methods for each of the 3 new AST nodes to the emitter, as well as adding clauses to the switch statement in the emit method.

We're also going to make it so it does a little bit of semantic checking in the emitter.

We're going to make the emitter check to see if a variable has been accessed prior to declaration in its scope, instead of adding a separate pass to the semantic analysis phase.

This isn't necessarily the best way to do it. Ideally, we would create a separate visitor to check that or do it in the type checker. However, it's the simplest way to do it in our case. Doing it like this prevents us from having to implement 2 passes during semantic analysis (and thus from having to maintain 2 environments in the REPL) or make the type checker more complicated.

First, let's update the imports for the emitter in src/emitter/Emitter.js:

import { makeSymbol } from "../runtime/makeSymbol.js";
Enter fullscreen mode Exit fullscreen mode

Now let's write the 3 emitter methods emitDoExpression, emitSetExpression, and emitVariableDeclaration:

  emitDoExpression(node, ns) {
    const childNs = ns.extend("doExpression");
    let code = "(() => {";

    let i = 0;
    for (let expr of node.body) {
      if (i === node.body.length - 1) {
        code += "return " + this.emit(expr, childNs) + "\n";
      } else {
        code += this.emit(expr, childNs) + "\n";
      }
      i++;
    }

    code += "})();";
    return code;
  }

  // more methods...
  emitSetExpression(node, ns) {
    return `${this.emit(node.lhv, ns)} = ${this.emit(node.expression, ns)};`;
  }

  // more methods...
  emitVariableDeclaration(node, ns) {
    const name = node.lhv.name;

    if (ns.has(name)) {
      throw new Exception(
        `Name ${name} defined at ${node.srcloc.file} ${node.srcloc.line}:${node.srcloc.col} has already been accessed in the current namespace; cannot access identifier before defining it`
      );
    }

    const translatedName = makeSymbol(name);

    ns.set(name, translatedName);

    return `var ${translatedName} = ${this.emit(node.expression, ns)};`;
  }
Enter fullscreen mode Exit fullscreen mode

Then in the switch statement in the emit method add the following cases just above the default case:

      case ASTTypes.VariableDeclaration:
        return this.emitVariableDeclaration(node, ns);
      case ASTTypes.SetExpression:
        return this.emitSetExpression(node, ns);
      case ASTTypes.DoExpression:
        return this.emitDoExpression(node, ns);
Enter fullscreen mode Exit fullscreen mode

Finally in the emitSymbol method, just before the return statement, add:

    // To make sure when compiling a variable definition the variable name hasn't already been accessed in the same scope
    if (!ns.has(name)) {
      ns.set(name, emittedName);
    }
Enter fullscreen mode Exit fullscreen mode

Changes to The AST Printer

Finally, let's wrap this up by making it so the AST Printer can print do blocks, variable declarations, and set expressions.

In src/printer/printAST.js, add these cases to the switch statement in the print method:

      // additional cases
      case ASTTypes.VariableDeclaration:
        return this.printVariableDeclaration(node, indent);
      case ASTTypes.SetExpression:
        return this.printSetExpression(node, indent);
      case ASTTypes.DoExpression:
        return this.printDoExpression(node, indent);
      // default case
Enter fullscreen mode Exit fullscreen mode

Then add the methods printDoExpression, printSetExpression, and printVariableDeclaration:

  printDoExpression(node, indent) {
    let prStr = `${prIndent(indent)}DoExpression:\n`;

    let i = 0;
    for (let expr of node.body) {
      prStr += `${this.print(expr, indent + 2)}`;
      prStr += i === node.body.length - 1 ? "" : "\n";
      i++;
    }

    return prStr;
  }

  printSetExpression(node, indent) {
    let prStr = `${prIndent(indent)}SetExpression:\n`;
    prStr += `${this.print(node.lhv, indent + 2)}\n`;
    prStr += `${this.print(node.expression, indent + 2)}`;
    return prStr;
  }

  printVariableDeclaration(node, indent) {
    let prStr = `${prIndent(indent)}VariableDeclaration:\n`;
    prStr += `${this.print(node.lhv, indent + 2)}\n`;
    prStr += `${this.print(node.expression, indent + 2)}`;
    return prStr;
  }
Enter fullscreen mode Exit fullscreen mode

Believe it or not, that's all it takes to add variable declarations, set expressions, and do blocks to Wanda! We'll make some changes to the CLI later to include multiline input in the REPL, but first let's turn our attention to the type checker.

Parsing and The Type Checker

The first thing we need to do is parse type annotations. We stubbed out the function earlier, but now we're going to fill it in.

We're also going to add type aliases to Wanda. This allows us to give names to types. This can make things more expressive for programmers. Let's say, for instance, that you have a function where age is represented by a number. If you want to refer to a value as age instead of number, just create a type alias:

(type age number)
Enter fullscreen mode Exit fullscreen mode

This is a simple example, but when types get more complex this feature will be more useful than it currently appears.

Let's look at src/parser/parseTypeAnnotation.js.

First, we'll construct an enum of Type Annotation Types:

export const TATypes = {
  NumberLiteral: "NumberLiteral",
  StringLiteral: "StringLiteral",
  BooleanLiteral: "BooleanLiteral",
  KeywordLiteral: "KeywordLiteral",
  NilLiteral: "NilLiteral",
  Symbol: "Symbol",
  List: "List",
};
Enter fullscreen mode Exit fullscreen mode

If an annotation is coming into the function as a Cons list we're just going to convert it to an array for simplicity's sake.

Then, if an annotation is an array, we'll pluck the 1st item for testing. Otherwise, the annotation is a simple form and we'll just use it as is.

Currently, the only values we have for testing are Tokens of type "Symbol." So we'll switch on the token's value property to see what kind of annotation it is.

The annotation will be either a literal type or a named type. Literal types include primitives like number, as well as lists and type aliases. A list has a list type, for example it could be list (string) with string as the list type.

Here's the parseTypeAnnotation function:

export const parseTypeAnnotation = (annotation) => {
  let annot;

  if (annotation instanceof Cons) {
    annotation = [...annotation];
  }

  if (Array.isArray(annotation)) {
    annot = annotation[0];
  } else {
    annot = annotation;
  }

  if (annot.type === TokenTypes.Symbol) {
    switch (annot.value) {
      case "number":
        return { kind: TATypes.NumberLiteral };
      case "string":
        return { kind: TATypes.StringLiteral };
      case "boolean":
        return { kind: TATypes.BooleanLiteral };
      case "keyword":
        return { kind: TATypes.KeywordLiteral };
      case "nil":
        return { kind: TATypes.NilLiteral };
      case "list":
        // annotation is array with listType as 2nd member
        // i.e. list (string) annotation == [list, string]
        return parseListAnnotation(annotation[1]);
      default:
        // must be a named type
        return { kind: TATypes.Symbol, name: annot.value };
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

The parseListAnnotation function is simple:

const parseListAnnotation = (type) => {
  const listType = parseTypeAnnotation(type);
  return { kind: TATypes.List, listType };
};
Enter fullscreen mode Exit fullscreen mode

We'll add one more node to the parser for type aliases. In src/parser/ast.js add a member to the ASTTypes enum:

export const ASTTypes = {
  // members...
  TypeAlias: "TypeAlias",
};
Enter fullscreen mode Exit fullscreen mode

Then add the TypeAlias node constructor:

export const AST = {
  // additional constructors...
  TypeAlias(name, type, srcloc) {
    return {
      kind: ASTTypes.TypeAlias,
      name,
      type,
      srcloc,
    };
  },
};
Enter fullscreen mode Exit fullscreen mode

Add a parseTypeAlias function to src/parser/parse.js:

const parseTypeAlias = (form) => {
  let [_, name, type] = form;
  name = name.value;
  const parsedType = parseTypeAnnotation(type);

  return AST.TypeAlias(name, parsedType, form.srcloc);
};
Enter fullscreen mode Exit fullscreen mode

And add the following case to the switch statement in parseList:

    // additional cases...
    case "type":
      return parseTypeAlias(form);
    // default case
Enter fullscreen mode Exit fullscreen mode

Now the parser is ready to handle type annotations and type aliases!

Emitting and Printing Type Aliases

The emitter and AST printer methods for type aliases both just return empty strings. JavaScript has no equivalent for a type alias, so there's no code to emit.

In src/emitter/Emitter.js add the type alias case to the switch in the emit method:

      // other cases
      case ASTTypes.TypeAlias:
        return this.emitTypeAlias(node, ns);
      // default case
Enter fullscreen mode Exit fullscreen mode

Then the emitTypeAlias method:

  emitTypeAlias(node, ns) {
    return "";
  }
Enter fullscreen mode Exit fullscreen mode

Then do the same in the switch statement in the print method in src/printer/printAST.js:

      // other cases...
      case ASTTypes.TypeAlias:
        return this.printTypeAlias(node, indent);
      // default case
Enter fullscreen mode Exit fullscreen mode

And the printTypeAlias method:

  printTypeAlias(node, indent) {
    return "";
  }
Enter fullscreen mode Exit fullscreen mode

Implementing The Type Checker

Here's a high-level overview of how the type checker works.

When the checker encounters an expression, it first tries to infer a type from the expression. If the expression uses a type annotation, it checks the inferred type against the annotated type to make sure the inferred type is a proper subtype of the annotated type. If it is, then we're all good. Otherwise, it throws an Exception.

The presence of a single type annotation will turn on checking for the entire program. A more sophisticated gradual type checker might only check variables and functions that were specifically annotated and leave the rest alone, but that would be much more complicated.

Types, Constructors, and Validators

The first thing we need to do is define our basic types. Then we'll create constructors and validators for the types so we can do the work of checking and returning types from the inference and checking functions.

Here's the src/typechecker/types.js file, which contains an enum of TypeTypes as well as JSDoc definitions for different type objects:

/**
 * @enum {string}
 */
export const TypeTypes = {
  Any: "Any",
  Number: "Number",
  String: "String",
  Boolean: "Boolean",
  Keyword: "Keyword",
  Nil: "Nil",
  FunctionType: "FunctionType",
  TypeAlias: "TypeAlias",
  List: "List",
};
/**
 * @typedef Any
 * @prop {TypeTypes.Any} kind
 */
/**
 * @typedef Number
 * @prop {TypeTypes.Number} kind
 */
/**
 * @typedef String
 * @prop {TypeTypes.String} kind
 */
/**
 * @typedef Boolean
 * @prop {TypeTypes.Boolean} kind
 */
/**
 * @typedef Keyword
 * @prop {TypeTypes.Keyword} kind
 */
/**
 * @typedef Nil
 * @prop {TypeTypes.Nil} kind
 */
/**
 * @typedef TypeAlias
 * @prop {TypeTypes.TypeAlias} kind
 * @prop {string} name
 * @prop {Type} base
 */
/**
 * @typedef FunctionType
 * @prop {TypeTypes.FunctionType} kind
 * @prop {Type[]} params
 * @prop {Type} ret
 * @prop {boolean} variadic
 */
/**
 * @typedef List
 * @prop {TypeTypes.List} kind
 * @prop {Type} listType
 */
/**
 * @typedef {Number|String|Boolean|Keyword|Nil} PrimitiveTypes
 */
/**
 * @typedef {Any|PrimitiveTypes|FunctionType|TypeAlias|List} Type
 */
Enter fullscreen mode Exit fullscreen mode

Note the existence of an Any type. Since Wanda is gradually typed, we need a case to handle types when type checking is turned off. The Any type is compatible with every other type and every other type is compatible with it, meaning any time you check against an Any type the check will pass. This has the effect of basically turning off the type checker.

Obviously as we add new kinds of types (like object types) this file will grow. Notice that we have a type for FunctionType even though we haven't added functions to the language yet. That's because we need it for checking call expressions.

Next we have type constructors. These return objects shaped like the type definitions in types.js. In src/typechecker/constructors.js:

import { TypeTypes } from "./types.js";

export const any = { kind: TypeTypes.Any };

export const number = { kind: TypeTypes.Number };

export const string = { kind: TypeTypes.String };

export const boolean = { kind: TypeTypes.Boolean };

export const keyword = { kind: TypeTypes.Keyword };

export const nil = { kind: TypeTypes.Nil };

export const functionType = (params, ret, variadic = false) => {
  return { kind: TypeTypes.FunctionType, params, ret, variadic };
};

export const typeAlias = (name, base) => ({
  kind: TypeTypes.TypeAlias,
  name,
  base,
});

export const listType = (listType) => ({ kind: TypeTypes.List, listType });
Enter fullscreen mode Exit fullscreen mode

Now let's combine our TypeType enum, constructors, and validators together into a single Type module. We'll add more to this module later, but this will do for now. In src/typechecker/Type.js:

import { TypeTypes } from "./types.js";
import * as Validators from "./validators.js";
import * as Constructors from "./constructors.js";

export const Type = {
  Type: TypeTypes,
  ...Constructors,
  ...Validators,
};
Enter fullscreen mode Exit fullscreen mode

From Annotations to Types

Next we need to be able to convert type annotations into concrete types. In src/typechecker/fromTypeAnnotation.js:

import { TATypes } from "../parser/parseTypeAnnotation.js";
import { Type } from "./Type.js";
import { Exception } from "../shared/exceptions.js";

export const fromTypeAnnotation = (typeAnnotation, typeEnv) => {
  switch (typeAnnotation.kind) {
    case TATypes.NumberLiteral:
      return Type.number;
    case TATypes.StringLiteral:
      return Type.string;
    case TATypes.BooleanLiteral:
      return Type.boolean;
    case TATypes.KeywordLiteral:
      return Type.keyword;
    case TATypes.NilLiteral:
      return Type.nil;
    case TATypes.Symbol: {
      const name = typeAnnotation.name;
      const type = typeEnv.getType(name);

      if (!type) {
        throw new Exception(
          `Type ${name} not found in current type environment`
        );
      }

      return type;
    }
    case TATypes.List: {
      const listType = fromTypeAnnotation(typeAnnotation.listType, typeEnv);
      return Type.listType(listType);
    }
    default:
      throw new Exception(
        `Type not found for type annotation ${JSON.parse(
          typeAnnotation,
          null,
          2
        )}`
      );
  }
};
Enter fullscreen mode Exit fullscreen mode

Converting Types to Strings

We'll also need a function to convert types to strings for things like error messages. In src/typechecker/typeToString.js:

import { Exception } from "../shared/exceptions.js";
import { TypeTypes } from "./types.js";

export const typeToString = (type) => {
  switch (type.kind) {
    case TypeTypes.Any:
      return "any";
    case TypeTypes.Number:
      return "number";
    case TypeTypes.String:
      return "string";
    case TypeTypes.Boolean:
      return "boolean";
    case TypeTypes.Keyword:
      return "keyword";
    case TypeTypes.Nil:
      return "nil";
    case TypeTypes.FunctionType:
      return `(${type.params.map(typeToString).join(", ")}) -> ${typeToString(
        type.ret
      )}`;
    case TypeTypes.TypeAlias:
      return `TypeAlias: ${type.name}, base: ${typeToString(type.base)}`;
    case TypeTypes.List:
      return `list (${typeToString(type.listType)})`;
    default:
      throw new Exception(`String conversion not implemented for ${type.kind}`);
  }
};
Enter fullscreen mode Exit fullscreen mode

Now add typeToString to the Type module in src/typechecker/Type.js:

import { typeToString } from "./typeToString.js";

export const Type = {
  // existing members...
  toString: typeToString,
};
Enter fullscreen mode Exit fullscreen mode

Subtypes

The main purpose of the check function we're going to write in a minute is to compare a type to a node and see if the node is a subtype of the provided type. To that end, we need a function to check if one type is a subtype of another.

Add this function in src/typechecker/isSubtype.js to check if type1 is a subtype of type2:

import { Type } from "./Type.js";

export const isSubtype = (type1, type2) => {
  if (Type.isAny(type1) || Type.isAny(type2)) return true;
  if (Type.isNumber(type1) && Type.isNumber(type2)) return true;
  if (Type.isString(type1) && Type.isString(type2)) return true;
  if (Type.isBoolean(type1) && Type.isBoolean(type2)) return true;
  if (Type.isKeyword(type1) && Type.isKeyword(type2)) return true;
  if (Type.isNil(type1) && Type.isNil(type2)) return true;

  if (Type.isTypeAlias(type1) && Type.isTypeAlias(type2)) {
    return isSubtype(type1.base, type2.base);
  }

  if (Type.isTypeAlias(type1) && !Type.isTypeAlias(type2)) {
    return isSubtype(type1.base, type2);
  }

  if (Type.isTypeAlias(type2) && !Type.isTypeAlias(type1)) {
    return isSubtype(type1, type2.base);
  }

  if (Type.isList(type1) && Type.isList(type2)) {
    return isSubtype(type1.listType, type2.listType);
  }

  return false;
};
Enter fullscreen mode Exit fullscreen mode

The Type Environment

Similar to the Namespace we use in the emitter, we also need a compile-time environment for the type checker. We'll call this class TypeEnvironment and have it inherit from Namespace.

Note that I've moved the Namespace class from src/runtime to src/shared. If you do the same, you'll need to update your imports accordingly.

In src/typechecker/TypeEnvironment.js:

import { Namespace } from "../shared/Namespace.js";

export class TypeEnvironment extends Namespace {
  constructor(parent = null, { name = "global" } = {}) {
    super(parent, { name });
    this.types = new Map();
    this.checkingOn = false;
  }

  static new(parent = null, { name = "global" } = {}) {
    return new TypeEnvironment(parent, { name });
  }

  existsType(name) {
    return this.lookupType(name) !== null;
  }

  extend(name) {
    return new TypeEnvironment(this, { name });
  }

  getType(name) {
    const scope = this.lookupType(name);

    if (scope) {
      return scope.types.get(name);
    }

    return null;
  }

  hasType(name) {
    return this.types.has(name);
  }

  lookupType(name) {
    let scope = this;

    while (scope) {
      if (scope.types.has(name)) {
        return scope;
      }

      scope = scope.parent;
    }

    return null;
  }

  setType(name, type) {
    this.types.set(name, type);
  }
}
Enter fullscreen mode Exit fullscreen mode

Note the checkingOn property set in the constructor. We'll set that property to true if we encounter a type annotation so the checker knows to check the types associated with variables and, when we have functions, function parameter and return types against the types it infers from expressions.

Getting The Base Type of A Type Alias

We also need to be able to get the base type of a type alias so we can properly type check annotations that name an alias. In src/typechecker/utils.js create the getAliasBase function:

import { Type } from "./Type.js";

export const getAliasBase = (name, env) => {
  let baseType = env.getType(name);

  while (Type.isTypeAlias(baseType)) {
    baseType = env.getType(baseType.name);
  }

  return baseType;
};
Enter fullscreen mode Exit fullscreen mode

Inferring Types from Expressions

Now we're getting into the real meat of the type checking implementation.

Our type checker is going to be bidirectional, which means it can get types either from annotations or from the expressions themselves, which allows us to drastically reduce the number of type annotations required to properly type check a program.

We don't have functions in the language yet, but when we do we'll be able to type check most programs based solely on the annotations on functions and their parameters.

This is how the TypeScript type checker works, and our type system is heavily influenced by TypeScript's.

To that end, we need an infer function that infers types from expressions.

In src/typechecker/infer.js you can see that the main infer function dispatches to sub-functions:

import { ASTTypes } from "../parser/ast.js";
import { Exception } from "../shared/exceptions.js";
import { Type } from "./Type.js";
import { isSubtype } from "./isSubtype.js";
import { getAliasBase } from "./utils.js";
import { fromTypeAnnotation } from "./fromTypeAnnotation.js";

export const infer = (ast, env) => {
  switch (ast.kind) {
    case ASTTypes.NumberLiteral:
      return inferNumber();
    case ASTTypes.StringLiteral:
      return inferString();
    case ASTTypes.BooleanLiteral:
      return inferBoolean();
    case ASTTypes.KeywordLiteral:
      return inferKeyword();
    case ASTTypes.NilLiteral:
      return inferNil();
    case ASTTypes.Symbol:
      return inferSymbol(ast, env);
    case ASTTypes.CallExpression:
      return inferCallExpression(ast, env);
    case ASTTypes.VariableDeclaration:
      return inferVariableDeclaration(ast, env);
    case ASTTypes.SetExpression:
      return inferSetExpression(ast, env);
    case ASTTypes.DoExpression:
      return inferDoExpression(ast, env);
    case ASTTypes.TypeAlias:
      return inferTypeAlias(ast, env);
    default:
      throw new Exception(`No type inferred for AST node type ${ast.kind}`);
  }
};
Enter fullscreen mode Exit fullscreen mode

Inferring for literal expressions is simple. We just return the type constructor for the literal node:

// Infer types from literal nodes
const inferNumber = () => Type.number;
const inferString = () => Type.string;
const inferBoolean = () => Type.boolean;
const inferKeyword = () => Type.keyword;
const inferNil = () => Type.nil;
Enter fullscreen mode Exit fullscreen mode

For inferSymbol we need to look up the symbol in the type environment, then get the base type if the symbol resolves to a type alias:

const inferSymbol = (node, env) => {
  const name = node.name;
  const namedType = env.get(name);
  const baseType = Type.isTypeAlias(namedType)
    ? getAliasBase(namedType.name)
    : namedType;

  if (!namedType) {
    throw new Exception(
      `Type not found in current environment for ${name} at ${node.srcloc.file} ${node.srcloc.col}:${node.srcloc.col}`
    );
  }

  return baseType;
};
Enter fullscreen mode Exit fullscreen mode

For inferCallExpression we infer the type for the function. If the function is of type Any we just return the Any type.

Otherwise we check to make sure the number of arguments is correct. For variadic functions this means a number of arguments equal to or greater than the function's arity (number of arguments it takes). For all other functions, this means the same number of arguments as the function's arity.

If type checking is on, we check the types of the arguments against the types of the formal parameters. This includes checking variadic arguments against the type of the variadic parameter.

Finally, if everything checks out, we return the function's return type.

const inferCallExpression = (node, env) => {
  const func = infer(node.func, env);

  if (Type.isAny(func)) {
    return Type.any;
  }

  if (
    node.args.length !== func.params.length ||
    (func.variadic && node.args.length >= func.params.length - 1)
  ) {
    throw new Exception(
      `Expected${func.variadic ? " at least " : " "}arguments; ${
        node.args.length
      } given at ${node.srcloc.file} ${node.srcloc.line}:${node.srcloc.col}`
    );
  }

  if (env.checkingOn) {
    func.params.forEach((p, i, a) => {
      const argType = infer(node.args[i], env);
      if (!isSubtype(argType, p)) {
        const node = node.args[i];
        throw new Exception(
          `${Type.toString(argType)} is not a subtype of ${Type.toString(
            p
          )} at ${node.srcloc.file} ${node.srcloc.line}:${node.srcloc.col}`
        );
      }

      if (i === a.length - 1) {
        for (let arg of node.args.slice(i)) {
          const argType = infer(arg, env);

          if (!isSubtype(argType, p)) {
            const node = node.args[i];
            throw new Exception(
              `${Type.toString(argType)} is not a subtype of ${Type.toString(
                p
              )} at ${node.srcloc.file} ${node.srcloc.line}:${node.srcloc.col}`
            );
          }
        }
      }
    });
  }

  return func.ret;
};
Enter fullscreen mode Exit fullscreen mode

We need a function inferVariableDeclaration because we need to be able to infer a type for every expression that could be in a program or do block, but primary checking of variable declarations will happen in the TypeChecker class so we'll just infer the type of node.expression and call it good.

const inferVariableDeclaration = (node, env) => {
  return infer(node.expression, env);
};
Enter fullscreen mode Exit fullscreen mode

inferSetExpression is the same:

const inferSetExpression = (node, env) => {
  return infer(node.expression, env);
};
Enter fullscreen mode Exit fullscreen mode

inferDoExpression extends the type environment and uses that to infer each expression it contains, then returns the type of the last expression.

const inferDoExpression = (node, env) => {
  let doType = Type.any;
  const doEnv = env.extend("doExpression");

  for (let expr of node.body) {
    doType = infer(expr, doEnv);
  }

  return doType;
};
Enter fullscreen mode Exit fullscreen mode

Lastly, inferTypeAlias returns the base type of a type alias declaration:

const inferTypeAlias = (node, env) => {
  return fromTypeAnnotation(node.type, env);
};
Enter fullscreen mode Exit fullscreen mode

We'll revisit this function when we add generics to Wanda, but this will work for now.

Checking Types

The last bit of machinery we need before building the type checker proper is a check function that takes a node, a type, infers the type from the node, and checks that type against the provided type. In src/typechecker/check.js:

export const check = (ast, type, env) => {
  const inferredType = infer(ast, env);

  if (!isSubtype(inferredType, type)) {
    throw new Exception(
      `Type ${Type.toString(inferredType)} is not a subtype of ${Type.toString(
        type
      )} at ${ast.srcloc.file} ${ast.srcloc.line}:${ast.srcloc.col}`
    );
  }
};
Enter fullscreen mode Exit fullscreen mode

The TypeChecker Class

Now we'll create a class that implements the visitor pattern, visits each node of the AST, and type checks it. Each method will return a modified AST node that includes type information.

For most of the nodes we have so far, this is as simple as calling infer on the provided node. One of the nice things about bidirectional type checking is that you can get your inference to go a long way, which saves the programmer the need to provide copious type annotations.

Let's construct the TypeChecker class in src/typechecker/TypeChecker.js:

import { ASTTypes } from "../parser/ast.js";
import { Exception } from "../shared/exceptions.js";
import { check } from "./check.js";
import { infer } from "./infer.js";
import { Type } from "./Type.js";
import { fromTypeAnnotation } from "./fromTypeAnnotation.js";

export class TypeChecker {
  constructor(program, env) {
    this.program = program;
    this.env = env;
  }

  static new(program, env = TypeEnvironment.new()) {
    return new TypeChecker(program, env);
  }
}
Enter fullscreen mode Exit fullscreen mode

Next we'll write the check method, which dispatches to other methods based on the AST node's kind property:

  check(node = this.program, env = this.env) {
    switch (node.kind) {
      case ASTTypes.Program:
        return this.checkProgram(node, env);
      case ASTTypes.NumberLiteral:
        return this.checkNumber(node, env);
      case ASTTypes.StringLiteral:
        return this.checkString(node, env);
      case ASTTypes.BooleanLiteral:
        return this.checkBoolean(node, env);
      case ASTTypes.KeywordLiteral:
        return this.checkKeyword(node, env);
      case ASTTypes.NilLiteral:
        return this.checkNil(node, env);
      case ASTTypes.Symbol:
        return this.checkSymbol(node, env);
      case ASTTypes.CallExpression:
        return this.checkCallExpression(node, env);
      case ASTTypes.VariableDeclaration:
        return this.checkVariableDeclaration(node, env);
      case ASTTypes.SetExpression:
        return this.checkSetExpression(node, env);
      case ASTTypes.DoExpression:
        return this.checkDoExpression(node, env);
      case ASTTypes.TypeAlias:
        return this.checkTypeAlias(node, env);
      default:
        throw new Exception(`Type checking not implemented for ${node.kind}`);
    }
  }
Enter fullscreen mode Exit fullscreen mode

The checkProgram method loops over node.body and checks each expression:

  checkProgram(node, env) {
    /** @type {TypedAST[]} */
    let body = [];
    let type;
    let i = 0;
    for (let expr of node.body) {
      if (i === node.body.length - 1) {
        const node = this.check(expr, env);
        type = node.type;
        body.push(node);
      } else {
        const node = this.check(expr, env);
        body.push(node);
      }
    }

    return { kind: node.kind, body, srcloc: node.srcloc, type };
  }
Enter fullscreen mode Exit fullscreen mode

The primitive check methods simply infer the type from the node:

  checkBoolean(node, env) {
    return { ...node, type: infer(node, env) };
  }

  checkKeyword(node, env) {
    return { ...node, type: infer(node, env) };
  }

  checkNil(node, env) {
    return { ...node, type: infer(node, env) };
  }

  checkNumber(node, env) {
    return { ...node, type: infer(node, env) };
  }

  checkString(node, env) {
    return { ...node, type: infer(node, env) };
  }
Enter fullscreen mode Exit fullscreen mode

The checkSymbol method does the same, since infer looks up the type for the symbol from the type environment already:

  checkSymbol(node, env) {
    return { ...node, type: infer(node, env) };
  }
Enter fullscreen mode Exit fullscreen mode

The checkCallExpression does the same since infer handles all the heavy lifting:

  checkCallExpression(node, env) {
    // infer handles checking for argument types
    return { ...node, type: infer(node, env) };
  }
Enter fullscreen mode Exit fullscreen mode

The checkDoExpression method loops over each expression in the body and checks them one at a time:

  checkDoExpression(node, env) {
    /** @type {TypedAST[]} */
    let body = [];
    for (let expr of node.body) {
      const node = this.check(expr, env);
      body.push(node);
    }

    return {
      kind: node.kind,
      body,
      srcloc: node.srcloc,
      type: infer(node, env),
    };
  }
Enter fullscreen mode Exit fullscreen mode

The checkVariableDeclaration method checks if the node has a type annotation. If so, it derives the type from the annotation and uses that to check the type of the expression. Then it sets the annotation type in the environment and turns on type checking for the whole program.

If there's no annotation, it infers the type of the expression and sets that in the environment.

  checkVariableDeclaration(node, env) {
    if (node.typeAnnotation) {
      const annotType = fromTypeAnnotation(node.typeAnnotation);
      check(node.expression, annotType, env);
      env.checkingOn = true;
      env.set(node.lhv.name, annotType);
      return { ...node, type: annotType };
    }

    const type = infer(node, env);
    env.set(node.lhv.name, type);
    return {
      kind: node.kind,
      lhv: node.lhv,
      expression: this.check(node.expression, env),
      srcloc: node.srcloc,
      type,
    };
  }
Enter fullscreen mode Exit fullscreen mode

checkSetExpression checks to see if checking is turned on. If so, it checks the type of its expression against the type set in the environment for the symbol. Otherwise it infers the type from the expression.

  checkSetExpression(node, env) {
    if (env.checkingOn) {
      const nameType = env.getType(node.lhv.name);
      check(node.expression, nameType, env);
      return { ...node, type: nameType };
    }

    return {
      kind: node.kind,
      lhv: node.lhv,
      expression: this.check(node.expression, env),
      srcloc: node.srcloc,
      type: infer(node, env),
    };;
  }
Enter fullscreen mode Exit fullscreen mode

Finally, checkTypeAlias gets the type from the annotation on the TypeAlias node and sets it in the type environment:

  checkTypeAlias(node, env) {
    const type = fromTypeAnnotation(node.type, env);
    env.setType(node.name, type);
    return { ...node, type };
  }
Enter fullscreen mode Exit fullscreen mode

Constructing The Global Type Environment

Now we need to make a way to construct the global type environment, including types for core library functions.

We're going to loop over the items of the core library and set the name to the value of the contract property on the core library function, defaulting to the Any type if there is no contract.

We're not going to go back and add contracts to all the core library functions because we don't actually have the ability to parse function type annotations yet, so for now that means everything gets the Any type. Obviously we'll change that in a future article.

Write the makeGlobalTypeEnv function in src/typechecker/makeGlobalTypeEnv.js:

import { theModule as Core } from "../../lib/js/core.js";
import { makeRuntime } from "../runtime/makeRuntime.js";
import { Type } from "./Type.js";
import { TypeEnvironment } from "./TypeEnvironment.js";

export const makeGlobalTypeEnv = () => {
  const mod = Core.module(makeRuntime());
  const typeEnv = TypeEnvironment.new(null, { name: "global" });

  for (let [k, v] of Object.entries(mod)) {
    typeEnv.set(k, v.contract ?? Type.any);
  }

  return typeEnv;
};
Enter fullscreen mode Exit fullscreen mode

Now when we do add contracts for core library functions they'll be automatically added to the global type environment.

A Functional Wrapper

The last thing we're going to do with the type checker is create a functional wrapper like we have with the other stages of the compilation pipeline. In src/typechecker/typecheck.js:

import { TypeChecker } from "./TypeChecker.js";

export const typecheck = (ast, env = undefined) =>
  TypeChecker.new(ast, env).check();
Enter fullscreen mode Exit fullscreen mode

Changes To The CLI

Now that we have everything set up for our type checker, let's make it so we can use it in our CLI.

First, we need to update our compile function to add type checking to the pipeline. In src/cli/compile.js:

import { tokenize } from "../lexer/tokenize.js";
import { read } from "../reader/read.js";
import { expand } from "../expander/expand.js";
import { parse } from "../parser/parse.js";
import { desugar } from "../desugarer/desugar.js";
import { emit } from "../emitter/emit.js";
import { typecheck } from "../typechecker/typecheck.js";

export const compile = (
  input,
  file = "stdin",
  ns = undefined,
  typeEnv = undefined
) =>
  emit(
    desugar(typecheck(parse(expand(read(tokenize(input, file)))), typeEnv)),
    ns
  );
Enter fullscreen mode Exit fullscreen mode

We also need to add a type environment creator param for our compileAndBuild function, in src/cli/compileAndBuild.js:

import { makeGlobalNameMap } from "../runtime/makeGlobals.js";
import { emitGlobalEnv } from "../emitter/emitGlobalEnv.js";
import { build } from "./build.js";
import { compile } from "./compile.js";
import { makeGlobalTypeEnv } from "../typechecker/makeGlobalTypeEnv.js";

export const compileAndBuild = (
  wandaCode,
  {
    fileName = "stdin",
    contextCreator = emitGlobalEnv,
    nsCreator = makeGlobalNameMap,
    outName = "main.js",
    moduleName = "main",
    typeEnvCreator = makeGlobalTypeEnv,
  } = {}
) => {
  const contextCode = contextCreator();
  const compiledCode = `${contextCode}
  ${moduleName}.result = ${compile(
    wandaCode,
    fileName,
    nsCreator(),
    typeEnvCreator()
  )}`;

  return build(compiledCode, outName, moduleName);
};
Enter fullscreen mode Exit fullscreen mode

We're also going to make some changes so the REPL can take multiline input to make it easier to write do blocks. First, add the file src/cli/utils.js and add these 2 functions:

export const countIndent = (str) => {
  let indentCount = 0;

  for (let char of str) {
    if (char === "(" || char === "[" || char === "{") {
      indentCount++;
    } else if (char === ")" || char === "]" || char === "}") {
      indentCount--;
    }
  }

  return indentCount;
};

export const inputFinished = (input) => countIndent(input) === 0;
Enter fullscreen mode Exit fullscreen mode

We don't have tokens in the language yet for square brackets and curly braces, but we will when we add Vectors and Records in the next article, so I'm just going ahead and putting them in this function now. That way we don't have to remember to edit it later.

Now we'll move the input variable outside of the while loop, add a variable for indentation, and handle the case where the programmer has more open parens than closing ones.

We also need to create a type environment to store type information in the REPL.

Here's the new src/cli/repl.js:

import os from "os";
import vm from "vm";
import readlineSync from "readline-sync";
import { pprintAST, pprintDesugaredAST } from "./pprint.js";
import { println } from "../printer/println.js";
import { makeGlobalNameMap } from "../runtime/makeGlobals.js";
import { emitGlobalEnv } from "../emitter/emitGlobalEnv.js";
import { build } from "./build.js";
import { compile } from "./compile.js";
import { makeGlobalTypeEnv } from "../typechecker/makeGlobalTypeEnv.js";
import { countIndent, inputFinished } from "./utils.js";

const read = (prompt) => readlineSync.question(prompt);

export const repl = (mode = "repl") => {
  // Create global compile environment
  const compileEnv = makeGlobalNameMap();
  const typeEnv = makeGlobalTypeEnv();

  // Build global module and instantiate in REPL context
  // This should make all compiled global symbols available
  const globalCode = build(emitGlobalEnv());
  vm.runInThisContext(globalCode);

  let prompt = "wanda> ";
  let input = "";
  let indent = 0;

  while (true) {
    try {
      input += read(prompt + "  ".repeat(indent));

      switch (input) {
        // If it's a command, execute it
        case ":quit":
          process.exit(0);
        case ":print-ast":
          mode = "printAST";
          break;
        case ":print-desugared":
          mode = "printDesugared";
          break;
        // If it's code, compile and run it
        default:
          if (inputFinished(input)) {
            let compiled = compile(input, "stdin", compileEnv, typeEnv);
            let result = vm.runInThisContext(compiled);

            if (mode === "printAST") {
              console.log(pprintAST(input));
            } else if (mode === "printDesugared") {
              console.log(pprintDesugaredAST(input));
            }

            println(result);
            input = "";
            indent = 0;
          } else {
            input += os.EOL;
            indent = countIndent(input);
          }
      }
    } catch (e) {
      console.error(e.message);
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

If you have a bin directive in your package.json file that points to bin/wanda.js and you haven't already used npm link in the directory, you can do so now. This will make wanda available as a global system command.

Now let's see the type checker in action. Run wanda in your terminal, then try to create a variable with a type annotation. Something like this:

(var (x : number) 10)
Enter fullscreen mode Exit fullscreen mode

It should work, and if you input x and hit enter in the CLI now you should get back the value 10.

If you try with a mismatched annotation and expression type, it should throw an error. Try it with something like:

(var (s : string) 7)
Enter fullscreen mode Exit fullscreen mode

You should get an error. Neat, eh?

Conclusion

Now we've got variables, do blocks, and type checking. This language is really coming along! Next time we'll add Vectors and Records to the language, as well as extending the type checker to handle union types.

As always, you can see the current state of the code on the current tag in the Wanda repo. There are also tests you can run with npm run test as well as JSDoc comments for nearly all functions and types (I can't promise I've remembered them all).

I'll see you in the next one!


  1. The type checker implementation owes much to Jake Donham's Reconstructing TypeScript series 

Top comments (0)