Macros

Macros are JavaScript functions that run at build time. The value returned by a macro is inlined into the bundle in place of the original function call. This allows you to generate constants, code, and even additional assets without any custom plugins.

Macros are imported using an import attribute to indicate that they should run at build time rather than being bundled into the output. You can import any JavaScript or TypeScript module as a macro, including built-in Node modules and packages from npm.

Note: for security reasons, macros cannot be called from inside node_modules.

This example uses the regexgen library to generate an optimized regular expression from a set of strings at build time.

import regexgen from 'regexgen' with {type: 'macro'};

const regex = regexgen(['foobar', 'foobaz', 'foozap', 'fooza']);
console.log(regex);

This compiles to the following bundle:

console.log(/foo(?:zap?|ba[rz])/);

As you can see, the regexgen library has been completely compiled away, and we are left with a static regular expression!

Arguments

#

Macro arguments are evaluated statically, which means their value must be known at build time. You can pass any JavaScript literal value, including strings, numbers, booleans, objects, etc. Simple expressions such as string concatenation, arithmetic, and comparison operators are supported as well.

import {myMacro} from './macro.ts' with {type: 'macro'};

const result = myMacro({
name: 'Devon'
});

However, values referencing non-constant variables, calling functions other than macros, etc. are not supported.

import {myMacro} from './macro.ts' with {type: 'macro'};

const result = myMacro({
name: getName() // Error: Cannot statically evaluate macro argument
});

Constants

#

Parcel also evaluates constants declared via the const keyword. These may be referenced in a macro argument.

import {myMacro} from './macro.ts' with {type: 'macro'};

const name = 'Devon';
const result = myMacro({name});

The result of one macro may also be passed to another macro.

import {myMacro} from './macro.ts' with {type: 'macro'};
import {getName} from './name.ts' with {type: 'macro'};

const name = getName();
const result = myMacro({name});

However, if you attempt to mutate the value of a constant, this will result in an error.

import {myMacro} from './macro.ts' with {type: 'macro'};

const arg = {name: 'Devon'};
arg.name = 'Peter'; // Error: Cannot statically evaluate macro argument

const result = myMacro({name});

Return values

#

Macros can return any JavaScript value, including objects, strings, booleans, numbers, and even functions. These are converted into an AST and replace the original function call in your code.

index.ts:
import {getRandomNumber} from './macro.ts' with {type: 'macro'};

console.log(getRandomNumber());
macro.ts:
export function getRandomNumber() {
return Math.random();
}

The bundled output of this example looks like this:

console.log(0.006024956627355804);

Async macros

#

Macros can also return promises that resolve to any supported value. For example, you could read a file asynchronously at build time, and inline the result into the bundle as a string.

index.ts:
import {readText} from './macro.ts' with {type: 'macro'};

console.log(readText('message.txt'));
macro.ts:
import fs from "fs/promises";

export async function readText(filePath: string) {
return fs.readFile(filePath, "utf8");
}

Generating functions

#

Macros can return functions, which allows you to generate code at build time. Use the new Function constructor to generate a function dynamically from a string.

This example uses the micromatch library to compile a glob matching function at build time.

index.ts:
import {compileGlob} from './glob.ts' with {type: 'macro'};

const isMatch = compileGlob('foo/**/bar.js');
glob.ts:
import micromatch from "micromatch";

export function compileGlob(glob) {
let regex = micromatch.makeRe(glob);
return new Function("string", `return ${regex}.test(string)`);
}

The bundled output of this example looks like this:

const isMatch = function (string) {
return /^(?:foo(?:\/(?!\.)(?:(?:(?!(?:^|\/)\.).)*?)\/|\/|$)bar\.js)$/.test(
string
);
};

Generating assets

#

A macro can generate additional assets which become dependencies of the JavaScript module that called it. For example, macros can generate CSS which will be statically extracted into a CSS bundle as if it was imported from the JS file.

Within a macro function, this is an object with Parcel-provided methods. To create an asset, call this.addAsset and provide the type and contents.

This example accepts a string of CSS and returns a generated class name. The CSS is added as an asset and bundled into a CSS file, and the JavaScript bundle only includes the generated class name as a static string.

index.ts:
import {css} from './css.ts' with {type: 'macro'};

<div className={css('color: red; &:hover { color: green }')}>
Hello!
</div>
css.ts:
import type {MacroContext} from '@parcel/macros';

export async function css(this: MacroContext | void, code: string) {
let className = hash(code);
code = `.${className} { ${code} }`;

this?.addAsset({
type: 'css',
content: code
});

return className;
}

The bundled output of the above example would look like this:

index.js:
<div className="ax63jk4">Hello!</div>
index.css:
.ax63jk4 {
color: red;
&:hover {
color: green;
}
}

Caching

#

By default, Parcel caches the result of a macro until the file that calls it changes. However, sometimes, a macro may have other inputs which should invalidate the cache. Parcel automatically tracks the files a macro reads using the fs module, along with the source code of the macro itself and any modules it imports. When one of these files changes, Parcel rebuilds your project and calls the macro again. Since macros may keep state between calls, a change to any of these files causes a full rebuild rather than only recompiling the files that call the macro.

For example, whenever message.txt is edited, index.ts will be recompiled and the readFile macro will be called again.

index.ts:
import {readFile} from './macro.ts' with {type: 'macro'};

console.log(readFile('message.txt'))
macro.ts:
import fs from "fs";

export function readFile(filePath: string) {
return fs.readFileSync(filePath, "utf8");
}
message.txt:
hello world!

Runtime environment

#

Macros run in a JavaScript runtime embedded in Parcel, rather than in Node.js. In addition to standard JavaScript, it supports a subset of Node and web APIs:

Other APIs, such as fetch, timers like setTimeout, and builtin modules like child_process and http, are not available within macros.

Usage with other tools

#

Macros are just normal JavaScript functions, so they integrate with other tools easily.

TypeScript

#

TypeScript supports import attributes out of the box as of version 5.3, and autocomplete and types for macros work just like regular functions.

Babel

#

The @babel/plugin-syntax-import-attributes plugin enables Babel to parse import attributes. If you're using @babel/preset-env, enabling the shippedProposals option also enables import attributes to be parsed.

babel.config.json:
{
"presets": [
[
"@babel/preset-env",
{
"shippedProposals": true
}
]
]
}

ESLint

#

ESLint supports import attributes when using a parser such as Babel or TypeScript that supports them.

.eslintrc.js:
module.exports = {
parser: '@typescript-eslint/parser'
};

Unit testing

#

Unit testing macros is just like testing any other JavaScript function. One caveat is if your macro uses the this context described in the above sections. If you are testing a macro itself, you can mock the this argument to verify it is called as expected.

css.test.ts:
import { css } from "../src/css.ts";

it("should generate css", () => {
let addAsset = jest.fn();
let className = css.call(
{
addAsset,
// ...
},
"color: red"
);

expect(addAsset).toHaveBeenCalledWith({
type: "css",
content: ".ax63jk4 { color: red }",
});
expect(className).toBe("ax63jk4");
});

When testing code that indirectly uses a macro, the macro function will be called as a normal function at runtime rather than by Parcel at compile time. In this case, the macro context that would normally be provided by Parcel won't be available. That's why the this argument is typed as MacroContext | void in the above examples and we do a runtime check to see if this exists. When the context isn't available, code that uses it such as this?.addAsset won't run, but the function should return a value as normal.

Differences from Bun

#

Macros via import attributes were originally implemented in Bun. Parcel's implementation is compatible with Bun's macro API for the most part, but there are a few differences: