Disclaimer: No AI was used in the making of this post
What are Macros?
Note: the syntax semantics are only an example for demonstration
Macros are a pre-processing step that allows developers to use templating-like syntax to describe code generation.
For example, an inline macro:
let result = add!(1, 1);
Prior to evaluation, would expand into:
let result = 2;
Why is this needed?
The Current Landscape
Almost every single non-trivial JavaScript and TypeScript application already uses "macros" (or at least the concept of). These macros are hard coded into the tools they use OR are added into custom compilers;
React
React, Preact, Solid, or any library that depends on JSX uses a built-in macro available in their transpiler
import { createElement } from 'react'
export App = () => <div>Hello World</div>
Expands into
import { createElement } from 'react'
export App = () => createElement('div', {}, 'Hello World')
Due to slight differences in implementation, there was a need to add configuration options like jsxFactory.
However, frameworks that want to take a different approach or wish to experiment with alternative approaches must use custom compilers.
Angular, Vue and Svelte
Angular uses a custom templating system powered by a custom compiler and a custom LSP.
Vue and Svelte use their own custom templating systems powered by custom compilers. Both require custom LSPs, and due to requiring a dedicated file extension, require additional tooling to support TypeScript, testing, etc
Styling Libraries
Styling libraries often use template literals or a combination of custom compilers and template literals to create programmatically defined controlled styling directives.
How do Macros Help?
Custom Language Syntax Becomes a Library Concern
Rather than tools like TypeScript's tsc, babel, swc, oxc, etc implementing expansion of jsx syntax and all other custom syntax formats (angular, vue, svelte, styling libs) requiring an additional bundler layer configured with custom compilers/loaders - the transformations for these libraries could be defined and exported by the libraries themselves
For example jsx could be described inside a normal .js file as:
import {jsx} from 'react'
const App = () => jsx!{<div>Hello World</div>}
Other frameworks/libraries could also leverage the same capability within .js files as:
import {vue} from 'vue'
export const MyComponent = function () {
return {
template: vue.html!{
<div>{this.data.value}</div>
},
data: {
value: 'Hello World'
},
}
}
This would drastically reduce the amount of tooling required to create a dynamic web application and allow the tools to distribute their custom transformations directly.
Custom Compilers and Build Tools have Short Shelf Lives
Many of the tools re-implement solutions to problems solved by other tools. The tools have high maintenance burdens and, unless they have large budgets, often have short lifespans. This means developers have to frequently hop from tool to tool without any material benefit to their workflow.
Running Code in the Browser and/or Runtime Directly
Currently, none of these custom implementations can be run directly in a JavaScript runtime without first requiring the pre-processing step.
Examples of the need for Macros
There are efforts to incorporate these custom transformations using tagged template literals, various eval hacks or baking external tooling into the runtime:
- GitHub - developit/htm: Hyperscript Tagged Markup: JSX alternative using standard tagged templates, with compiler support.
- Great for prototyping, however the code cannot be reused for production and will need to be rewritten when production use cases are targeted
- JSX Support · Issue #56822 · nodejs/node · GitHub
- While JSX support in Nodejs would help, anyone using additional tools (like styling libraries) would inevitably fall back to needing an external preprocessor
- This code could not run symmetrically in the browser
- GitHub - vuejs/petite-vue: 6kb subset of Vue optimized for progressive enhancement
- Compiles Vue templates in the browser however requires the use of unsafe
evaland any code written to target this would need to be rewritten to target production
- Compiles Vue templates in the browser however requires the use of unsafe
- GitHub - tc39/proposal-decorators: Decorators for ES6 classes
- Decorators aim to solve a similar problem however they are incomplete, difficult to use, cannot be compiled AOT and are only available on classes and class properties.
These approaches reimplement existing tools and are either unsafe or not-reusable/non-portable.
Having first class support in the EcmaScript specification for inline macros would consolidate all of these efforts
Compile Time Versus Run Time Evaluation
Macros are typically thought of as a pre-processing step done Ahead-Of-Time by a compiler. EcmaScript is different in that it's an evaluated language.
Having macros defined within the EcmaScript specification allows these pre-processing steps to be evaluated at runtime OR expanded/optimized by compilers in a build step prior to runtime.
This has the advantage of allowing source code to be use symmetrically at both run time and during the development loop.
Examples of Macros used for These Exact Use Cases
Here are some examples where the use of inline macros enabled the rapid experimentation and development of GUI frameworks and utilities without the need for an external transpiler.
- [1] Moved to comment due to links limit
- Rust wasm framework, similar to Vue, that adds html support into the language without an external transpiler
- [2]
- Rust wasm framework, similar to React, that adds jsx-like support to the language without an external transpiler
- [3]
- Rust native desktop framework that has a Flutter-like syntax to define elements/styles without an external transpiler
- [4]
- Rust library that adds inline json support to the language without an external transpiler
- [5]
- Rust library that adds inline xml support to the language without an external transpiler
- ...etc
If a developer wanted to experiment with a new/novel framework - they could just write it within the language and would not face the cliff of needing to build, document and maintain an external transpiler, LSP and integration tooling.
This lack of friction empowers developers/tool makers and fosters competition within the ecosystem which is highly valuable.
Macro Syntax
I won't dive into this now as I'd like to discuss the inclusion of macros into EcmaScript before getting into the technicals.
That said, there are a few key points.
- Macros must be expandable statically AOT by native transpilers (like
swcoxc) and therefore should not use ES syntax in their definition - Macros must not conflict with existing ES syntax
Below is a example of a macro syntax that meets point 1 (I am aware of the conflict of using !)
This is just an example based on Rust's inline macro templating system. If progressed, something similar would be defined in a more ES idiomatic way.
This is an inline macro that adds two numbers at at expand-time.
macro_rules! add {
($a:expr, $b:expr) => {
$a + $b
};
}
const result = add!(1, 1)
... That looks complicated
It is, but so is maintaining a transpiler, LSP and multiple bundler plugin/loaders for that custom transpiler - which is what we do today any way.
Ultimately, macros are intended to be used by framework/library maintainers/builders as a means to reduce complexity for consumers of those libraries. Outside of edge cases, it's not something most developers would actually write themselves.
Procedural Macros vs Inline Macros
There would perhaps need to be two proposals to incorporate both of these elements.
Inline Macros
Inline macros are simple "pure" templates that take arguments and are replaced by the result of their macro syntax
For example, I could write a library that does this conversion:
Users write:
import {css} from 'magic-styles';
const className = css!{ color: red; };
document.querySelector('#my-elm').classList.add(className);
Expands into:
import {css} from 'magic-styles'; // Can be stripped by dead-code elimination
const className = (() => {
const className = "xyz123"
const elm = document.createElement('style')
elm.innerHtml = `.${className} { color: red; }`
document.head.appendChild(elm)
return className
})();
document.querySelector('#my-elm').classList.add(className);
Procedural Macros (A Later Discussion)
I don't want to dive too deeply into this in this discussion, but I wanted to share as it's part of the larger vision.
Procedural Macros are similar to decorators, but can be applied to variable declarations, functions, classes, class members, etc, and augment the behavior of the target.
For example; Frameworks like Vue, Angular, Mobx, etc currently use complex combinations of compiler tricks and/or runtime libraries like signals to give users ergonomic access to detect state mutations and automatically trigger rendering calculations.
Procedural macros would grant both tool makers and developers alternative and/or simpler approaches.
For example, a procedural macro that in-place converts class properties into getters/setters;
import {reactive, subscribe} from 'reactive'
#[reactive.class]
class Foo {
#[reactive.push]
bar = 'Initial Value'
constructor() {
// This is difficult (or impossible?) to replicate with decorators
setTimeout(() => this.bar = 'Updated', 1000)
}
}
const foo = new Foo()
subscribe(foo, () => console.log('updated'))
Would expand into something like
import {subscribe} from 'reactive'
class Foo {
[Symbol.for('reactive-state')] = {
emitter: new EventTarget(),
bar: 'Initial Value',
}
get bar() { return this[Symbol.for('reactive-state')].bar}
set bar(update) {
this[Symbol.for('reactive-state')].bar = update
this[Symbol.for('reactive-state')].emitter
.dispatchEvent(new CustomEvent('reactive:update'))
}
constructor() {
setTimeout(() => this.bar = 'Updated', 1000)
}
}
const foo = new Foo()
subscribe(foo, () => console.log('updated'))
There are many other use cases, like simplified serialization/deserialization and creating RPC wrappers for instances located in Worker threads or external processes - however the key is that these implementation are done in user-land and can be compiled/optimized AOT by bundlers
Alternatives Considered
- Using an external macro compiler
- [6]
- [7]
- Adding this into TypeScript
- [8]
- [9]
The TypeScript team reject the idea of adding macros unless it's an EcmaScript feature (fair enough, it makes sense given the project goals).
Support for custom TypeScript transformers is going away with ts-go (see tool's have short shelf lives).
An external macro-aware compiler requires a custom LSP as well as documenting and maintaining plugins/loaders for bundlers.