-
Notifications
You must be signed in to change notification settings - Fork 9
Chaining Macros
Let's create a simple macro that takes any possible value and compares it with other values of the same type:
function $contains<T>(value: T, ...possible: Array<T>) {
return +["||", [possible], (item: T) => value === item];
}
We can call the above macro like a normal function, except with an exclamation mark (!
) after its name:
const searchItem = "google";
$contains!(searchItem, "erwin", "tj");
This is one way to call a macro, however, the ts-macros transformer allows you to also call the macro as if it's a property of a value - this way the value becomes the first argument to the macro:
searchItem.$contains!("erwin", "tg");
// Same as: $contains!(searchItem, "erwin", "tj");
If we try to transpile this code, typescript is going to give us a TypeError
- $contains
is not a member of type String
. When you're calling the macro like a normal function, typescript and the transformer are able to trace it to it's definition thanks to it's symbol
. They're internally connected, so the transpiler is always going to be able to find the macro.
When we try to chain a macro to a value, neither the transpiler nor the transformer are able to trace back the identifier $contains
to a function definition. The transformer fixes this by going through each macro it knows with the name $contains
and checking if the types of the parameters of the macro match the types of the passed arguments. To fix the typescript error we can either put a //@ts-expect-error
comment above the macro call or modify the String
type via ambient declarations:
declare global {
interface String {
$contains(...possible: Array<string>) : boolean;
}
}
Now if we run the code above in the playground we aren't going to get any errors and the code will transpile correctly!
On paper, this sounds like a nice quality-of-life feature, but you can use it for something quite powerful - transparent types. You are able to completely hide away a data source behind a type that in reality doesn't represent anything, and use macros to access the data source. Below are some ideas on how these transparent types could be used.
A Vector
type which in reality is just an array with two elements inside of it ([x, y]
):
// This represents our data source - the array.
interface Vector {
$x(): number;
$y(): number;
$data(): [number, number];
$add(x?: number, y?: number): Vector;
}
// Namespaces allow us to store macros
namespace Vector {
// Macro for creating a new Vector
export function $new(): Vector {
return [0, 0] as unknown as Vector;
}
// Macro which transforms the transparent type into the real type
export function $data(v: Vector) : [number, number] {
return v as unknown as [number, number];
}
export function $x(v: Vector) : number {
return $data!(v)[0];
}
export function $y(v: Vector) : number {
return $data!(v)[1];
}
export function $add(v: Vector, x?: number, y?: number) : Vector {
const $realData = $data!(v);
return [$realData[0] + (x || 0), $realData[1] + (y || 0)] as unknown as Vector;
}
}
const myVector = Vector.$new!().$add!(1).$add!(undefined, 10);
console.log(myVector.$x!(), myVector.$y!());
const myVector = [1, 10];
console.log(myVector[0], myVector[1]);
An iterator transparent type that allows us to use chaining for methods like $map
and $filter
, which expand to a single for loop when the iterator is collected with $collect
. Here the Iter
type isn't actually going to be used as a value in the code, instead, it's just going to get passed to the $map
, $filter
, and $collect
macros.
$next
is not actually a macro but an arrow function that is going to contain all the code inside the for loop. $map
and $filter
modify this arrow function by adding their own logic inside of it after the old body of the function, and the $collect
macro inlines the body function in the for loop.
interface Iter<T> {
_arr: T[],
$next(item: any) : T,
$map<K>(mapper: (item: T) => K) : Iter<K>,
$filter(fn: (item: T) => boolean) : Iter<T>,
$collect() : T[]
}
namespace Iter {
export function $new<T>(array: T[]) : Iter<T> {
return {
_arr: array,
$next: (item) => {}
} as Iter<T>;
}
export function $map<T, K>(iter: Iter<T>, mapper: (item: T) => K) : Iter<K> {
return {
_arr: iter._arr,
$next: (item) => {
$$inline!(iter.$next, [item]);
item = $$escape!($$inline!(mapper, [item], true));
}
} as unknown as Iter<K>;
}
export function $filter<T>(iter: Iter<T>, func: (item: T) => boolean) : Iter<T> {
return {
_arr: iter._arr,
$next: (item) => {
$$inline!(iter.$next, [item]);
if (!$$escape!($$inline!(func, [item], true))) $$ts!("continue");
}
} as Iter<T>;
}
export function $collect<T>(iter: Iter<T>) : T[] {
return $$escape!(() => {
const array = iter._arr;
const result = [];
for (let i=0; i < array.length; i++) {
let item = array[i];
$$inline!(iter.$next, [item]);
result.push(item);
}
return result;
});
}
}
const arr = Iter.$new!([1, 2, 3]).$map!(m => m * 2).$filter!(el => el % 2 === 0).$collect!();
const array_1 = [1, 2, 3];
const result_1 = [];
for (let i_1 = 1; i_1 < array_1.length; i_1++) {
let item_1 = array_1[i_1];
item_1 = item_1 * 2;
if (!(item_1 % 2 === 0))
continue;
result_1.push(item_1);
}
const myIter = arr;
Here we do the same thing as the iterator type:
namespace If {
export interface IfStatement {
_cond: any,
_then: () => any,
_else: () => any,
$then(exp: any) : IfStatement,
$else<T>(exp: any, doNotCompile?: true) : IfStatement
$else<T>(exp: any, doNotCompile?: boolean) : T|IfStatement,
$finish<T>() : T;
}
export function $cond(exp: any) : IfStatement {
return {
_cond: exp,
_then: () => {},
_else: () => {}
} as IfStatement;
}
export function $then(stmt: IfStatement, exp: any) : IfStatement {
return {
_cond: stmt._cond,
_else: stmt._else,
_then: () => {
$$escape!(stmt._then);
if ($$kindof!(exp) === SyntaxKind.ArrowFunction) return $$escape!(exp);
else return exp;
}
} as IfStatement;
}
export function $finish<T>(stmt: IfStatement) : T {
return $$escape!(() => {
let result;
if (stmt._cond) {
result = $$escape!(stmt._then);
} else {
result = $$escape!(stmt._else);
}
return result;
});
}
export function $else<T>(stmt: IfStatement, exp: any, doNotCompile?: true) : IfStatement;
export function $else<T>(stmt: IfStatement, exp: any, doNotCompile?: boolean) : T|IfStatement {
const $stmt = {
_cond: stmt._cond,
_then: stmt._then,
_else: () => {
$$escape!(stmt._else);
if ($$kindof!(exp) === SyntaxKind.ArrowFunction) return $$escape!(exp);
else return exp;
}
} as IfStatement;
if (doNotCompile === true) return $stmt;
else return $finish!($stmt);
}
}
let t: number = 1;
const res = If.$cond!(t === 1)
.$then!(() => {
console.log(3);
return 5;
})
.$else!(() => {
console.log(5);
return 10;
})
let t = 1;
let result_1;
if (t === 1) {
console.log(3);
result_1 = 5;
}
else {
console.log(5);
result_1 = 10;
}
const res = result_1;
The ts-macros transformer keeps tracks of macros using their unique symbol. Since you must declare the type for the macros yourself via ambient declarations, the macro function declaration and the type declaration do not share a symbol, so the transformer needs another way to see which macro you're really trying to call.
This is why the transformer compares the types of the parameters from the macro call site to all macros of the same name. Two types are considered equal if the type of the argument is assignable to the macro parameter type. For example:
// ./A
function $create(name: string, age: number) { ... }
// ./B
function $create(id: string, createdAt: number) { ... }
These two macros are perfectly fine, it's ok that they're sharing a name, the transformer can still differenciate them when they're used like this:
import { $create } from "./A";
import { $create as $create2 } from "./B";
$create!("Google", 44); // Valid
$create2!("123", Date.now()) // Valid
However, when either of the macros get used in chaining, the transformer is going to raise an error, because both macros have the exact same parameter types, in the exact same order - string
, number
.
The only ways to fix this are to either:
- Rename one of the macros
- Switch the order of the parameters
- Possibly brand one of the types