Skip to content

Built‐in Macros

Volen Slavchev edited this page Oct 9, 2023 · 2 revisions

ts-macros provides you with a lot of useful built-in macros which you can use inside macros. All the exported functions from this library that start with two dollar signs ($$) are built-in macros!

You cannot chain built-in macros!

$$loadEnv

Loads an env file from the provided path, or from the base directory of your project (aka where package.json is). The macro loads the environment variables in the output AND while typescript is transpiling your code. This means expressions like process.env.SOME_CONFIG_OPTION in macro bodies will be replaced with the literal value of the environment variable. This macro requires you to have the dotenv module installed. It doesn't come with the library by default.

import { $$loadEnv } from "ts-macros";
$$loadEnv!();

function $multiply(num: number) : number {
   process.env.TRIPLE === "yes" ? num * 3 : num * 2;
}

[$multiply!(1), $multiply!(2), (3).$multiply!()];
require("dotenv").config();
[3, 6, 9];
TRIPLE=yes

$$readFile

Reads the contents of the specified file and expands to them. If the parseJSON argument is set to true, then the contents get parsed to JSON and then expanded.

function $log(...contents: Array<unknown>) : void {
   if ($$readFile!<{debug: boolean}>("./test/config.json", true).debug) console.log(+[[contents], (content) => content]);
}
$log!("Hello", "World!");
console.log("Hello", "World!");
{ "debug": true }

$$inline

Inlines the provided function, replacing any argument occurrences with the corresponding values inside the params array, and expands the code.

If the function consists of a single expression, the call to $$inline expands to that expression, otherwise, it expands to an IIFE. Passing any value to the doNotCall parameter will make it so it always expands to an arrow function, so the code will NEVER be executed.

function $map<T>(arr: Save<Array<T>>, cb: (item: T) => T) : Array<T> {
   const res = [];
   for (let i=0; i < arr.length; i++) {
       res.push($$inline!(cb, [arr[I]]));
   }
   return res;
}
console.log($map!([1, 2, 3, 4, 5], (num: number) => num * 2));
let arr_1 = [1, 2, 3, 4, 5];
console.log((() => {
    const res = [];
    for (let i = 0; i < arr_1.length; i++) {
        res.push(arr_1[i] * 2);
    }
    return res;
})());

$$kindof

Returns the kind of the expression.

import * as ts from "typescript"

function $doSomethingBasedOnTypeOfParam(param: unknown) {
    if ($$kindof!(param) === ts.SyntaxKind.ArrayLiteralExpression) "Provided value is an array literal!";
    else if ($$kindof!(param) === ts.SyntaxKind.ArrowFunction) "Provided value is an arrow function!";
    else if ($$kindof!(param) === ts.SyntaxKind.CallExpression) "Provided value is a function call!";
}
$doSomethingBasedOnTypeOfParam!([1, 2, 3]);
$doSomethingBasedOnTypeOfParam!(console.log(1));
$doSomethingBasedOnTypeOfParam!(() => 1 + 1);
"Provided value is an array literal!";
"Provided value is a function call!";
"Provided value is an arrow function!";

$$define

Creates a const variable with the provided name and initializer. This is not hygienic, use it when you want to create a variable and know its name.

$$define!("abc", 123);
const abc = 123;

$$i

If this macro is called in a repetition, it's going to return the number of the current iteration. If it's called outside, it's going to return -1.

import { $$i } from "ts-macros";

function $arr(...els: Array<number>) {
   +["[]", [els], (element: number) => element + $$i!()];
}
$arr!(1, 2, 3);
[1, 3, 5]

$$length

Gets the length of an array or a string literal.

$$length!([1, 2, 3, 4, 5]);
5

$$ident

Turns a string literal into an identifier.

const Hello = "World";
console.log($$ident!("Hello"));
const Hello = "World";
console.log(Hello);

$$err

Throws an error during transpilation.

$$includes

Checks if a value is included in the array / string literal.

$$includes!([1, 2, 3], 2);
$$includes!("HellO!", "o");
true;
false;

$$slice

Slices a string literal or an array literal.

$$slice!("Hello", 0, 2);
$$slice!([1, 2, 3, 4], 2);
$$slice!([1, 2, 3, 4], -1);
"He";
[3, 4];
[4];

$$ts

Turns the provided string into code. You should use this only when you can't accomplish something with other macros.

type ClassInfo = { name: string, value: string };

export function $makeClasses(...info: Array<ClassInfo>) {
     +[(info: ClassInfo) => {
        $$ts!(`
           class ${info.name} {
                 constructor() {
                     this.value = ${info.value}
                 }
             }
         `);
     }];
}
$makeClasses!({name: "ClassA", value: "1"}, {name: "ClassB", value: "2"})
class ClassA {
   constructor() {
        this.value = 1;
    }
}
class ClassB {
   constructor() {
        this.value = 2;
    }
}

$$escape

"Escapes" the code inside the arrow function by placing it in the parent block. If the last statement inside the arrow function is a return statement, the escape macro itself will expand to the returned expression.

function $try(resultObj: any) {
   return $$escape!(() => {
      const res = resultObj;
      if (res.is_err()) {
          return res;
      }
      return res.result;
  });
}

const result = $try!({ value: 123 });
const res = { value: 123 };
if (res.is_err()) {
     return res;
}
const a = res.result;

$$propsOfType

Expands to an array with all the properties of a type.

console.log($$propsOfType!<{a: string, b: number}>());
console.log(["a", "b"]);

$$typeToString

Turns a type to a string literal.

console.log($$typeToString!<[string, number]>());
console.log("[string, number]")

$$typeAssignableTo

Checks if type T is assignable to type K.

$$comptime

This macro allows you to run typescript code during transpilation. It should only be used as an expression statement because it expands to nothing. Additionally, you cannot use macros inside the arrow function's body.

$$comptime!(() => {
// This will be logged when you're transpiling the code
console.log("Hello World!");
});

If this macro is used inside a function (can be any type of function - arrow function, function declaration, constructor, method, getter, setter, etc.), it will be run for every visible call to the function, so if the function is called inside a loop or an interval, the arrow function will be called once.

class User {

    send(message: string) {
       $$comptime!(() => {
            console.log("User#send was called somewhere!");
        });
    }
}

const me = new User();
me.send(); // Logs "User#send was called somewhere!" during transpilation
me.send(); // And again...
me.send(); // And again...

for (let i=0; i < 10; i++) {
    me.send(); // And again... only once though!
}

Also, you can access the function's parameters as long as they are literals:

const greet = (name: string) => {
    $$comptime!(() => {
        console.log(`Hello ${name}`);
    });
}

greet("Michael"); // Logs "Hello Michael" during transpilation
let name: string = "Bella";
greet(name); // Logs "Hello undefined"

Remember, this works only with literals like "ABC", 34, true, [1, 2, 3], {a: 1, b: 2}. You can also call other functions within it, but the functions must not use any outside variables. This macro is especially useful when you want to validate a function argument during compile-time:

function send(msg: string) {
   $$comptime!(() => {
        if (!msg.startsWith("C")) console.log("Message must start with C.");
    });
}
 
send("ABC")

$$raw

Allows you to interact with the raw typescript AST by passing an arrow function which will be invoked during the transpilation process, much like the $$comptime macro, except this macro gives you access to the parameters as AST nodes, not actual values.

This macro can only be used inside other macros, and the parameters of the arrow function should match the macros, except in AST form. The only exception to this is rest operators, those get turned into an array of expressions.

The first parameter of the function is a RawContext, which gives you access to everything exported by typescript so you don't have to import it.

Use the high-level tools provided by ts-macros if possible - they're easier to read and understand, they're more concise and most importantly you won't be directly using the typescript API which changes frequently.

import * as ts from "typescript";
// raw version
function $addNumbers(...numbers: Array<number>) {
  return $$raw!((ctx: RawContext, numsAst: Array<ts.Expression>) => {
    return numsAst.slice(1).reduce((exp, num) => ctx.factory.createBinaryExpression(exp, ctx.ts.SyntaxKind.PlusToken, num), numsAst[0]);
 });
}

// ts-macros version
function $addNumbers(...numbers: Array<number>) {
 return +["+", [numbers], (num) => num]
}

$$text

Expands to a string literal of the expression. If the transformation is not possible, it expands to undefined.

Expressions that can be transformed:

  • string literals
  • identifiers
  • numeric literals
  • true/false
  • undefined
  • null

$$decompose

Separates the passed expression to individual nodes, and expands to an array literal with the nodes inside of it.

Doesn't work on expressions that can contain statements, such as function expressions.

// Stringifies the passed expression without using any typescript API
function $stringify(value: any) : string {
  // Store the array literal in a macro variable
  const $decomposed = $$decompose!(value);
  if ($$kindof!(value) === ts.SyntaxKind.PropertyAccessExpression) return $stringify!($decomposed[0]) + "." + $stringify!($decomposed[1]);
  else if ($$kindof!(value) === ts.SyntaxKind.CallExpression) return $stringify!($decomposed[0]) + "(" + (+["+", [$$slice!($decomposed, 1)], (part: any) => {
      const $len = $$length!($decomposed) - 2;
      return $stringify!(part) + ($$i!() === $len ? "" : ", ");
  }] || "") + ")";
  else if ($$kindof!(value) === ts.SyntaxKind.StringLiteral) return "\"" + value + "\"";
  else return $$text!(value);
}
$stringify!(console.log(1, 2, console.log(3)));
"console.log(1, 2, console.log(3))";

$$map

Goes over all nodes of an expression and all it's children recursively, calling mapper for each node and replacing it with the result of the function, much like Array#map.

If mapper expands to null or nothing, the node doesn't get replaced.

The first parameter of the mapper function is the expression that's currently being visited, and the second parameter is the kind of the expression.

function $$replaceIdentifiers(exp: any, identifier: string, replaceWith: string) : any {
 return $$map!(exp, (value, kind) => {
    if (kind === ts.SyntaxKind.Identifier && $$text!(value) === identifier) return $$ident!(replaceWith);
 });
}
$$replaceIdentifiers!(console.log(1), "log", "debug");
const fn = $$replaceIdentifiers!((arr: number[]) => {
  for (const item of arr) {
     console.info(item);
  }
}, "console", "logger");
 console.debug(1);
 const fn = (arr) => {
   for (const item of arr) {
      logger.info(item);
  }
};

$$typeMetadata

Collects information about the provided type. Example

interface MyType {
    propA: number,
    propB: string,
    /**
     * @readonly
     * @default true
     */
    propC: boolean,
    someFn(paramA: number, paramB?: string) : number;
}

$$typeMetadata!<MyType>(true, true)
{
    name: "MyType", 
    properties: [{
        name: "propA", 
        tags: {}, 
        type: "number", 
        optional: false
      }, {
        name: "propB", 
        tags: {}, 
        type: "string", 
        optional: false
      }, {
        name: "propC", 
        tags: {
          readonly: true, 
          default: "true"
        }, 
        type: "boolean", 
        optional: false
      }], 
    methods: [{
        name: "someFn", 
        tags: {}, 
        parameters: [{
            name: "paramA", 
            type: "number", 
            optional: false
          }, {
            name: "paramB", 
            type: "string", 
            optional: false
          }], 
        returnType: "number"
      }]
  }