Macros are a mechanism for running JavaScript functions at bundle-time. The values returned by these functions are directly inlined into your bundle.
As a toy example, consider this simple function that returns a random number.
export function random() {
return Math.random();
}
This is just a regular function in a regular file, but we can use it as a macro like so:
import { random } from "./random.ts" with { type: "macro" };
console.log(`Your random number is ${random()}`);
Macros are identified using the import attributes syntax. If you haven’t seen this syntax before, it’s a Stage 3 TC39 proposal
that allows you to attach additional metadata to import statements.
Now let’s bundle this file with bun build. The bundled file will be printed to stdout.
console.log(`Your random number is ${0.6805550949689833}`);
As you can see, the source code of the random function doesn’t appear in the bundle. Instead, it was executed at bundle-time, and the function call (random()) was replaced with the result of the function. Because the source code is never included in the bundle, macros can safely perform privileged operations like reading from a database.
When to use macros
If you have several one-off build scripts for small tasks, executing code at bundle-time can be easier to maintain. Macros live alongside other code, run in sync with the build, are automatically parallelized, and fail the build if they fail.
However, if you find yourself running lots of code at bundle-time, consider running a server instead.
Import attributes
Bun macros are annotated with import statements using:
with { type: 'macro' } — An import attribute, a Stage 3 ECMA Script proposal
assert { type: 'macro' } — An import assertion, an older form of import attributes that is now deprecated (but already shipped in multiple browsers and runtimes)
Safety considerations
Macros must be explicitly imported with { type: "macro" } to be executed at bundle-time. Unless you call those macro imports, they have no effect, whereas normal JavaScript imports can have side effects.
You can completely disable macros by passing --no-macros to Bun, which will produce a build error like this:
error: Macros are disabled
foo();
^
./hello.js:3:1 53
To reduce the potential attack surface of malicious packages, macros cannot be called from inside node_modules/**/*. If a package tries to call a macro, you’ll see an error like this:
error: For security reasons, macros cannot be run from node_modules.
beEvil();
^
node_modules/evil/index.js:3:1 50
Your application code can still import macros from node_modules and call them.
import { macro } from "some-package" with { type: "macro" };
macro();
Export condition “macro”
When publishing a library with macros to npm or other package registries, use the "macro" export condition to provide a special version for the macro environment.
{
"name": "my-package",
"exports": {
"import": "./index.js",
"require": "./index.js",
"default": "./index.js",
"macro": "./index.macro.js"
}
}
This configuration allows users to consume your package using the same import specifier at runtime or bundle-time:
import pkg from "my-package"; // runtime import
import { macro } from "my-package" with { type: "macro" }; // macro import
The first import resolves to ./node_modules/my-package/index.js, while the second import is resolved by Bun’s bundler to ./node_modules/my-package/index.macro.js.
Execution
When the Bun transpiler encounters a macro import, it uses Bun’s JavaScript runtime to call the function inside the transpiler and convert the JavaScript return value to an AST node. These functions are called at bundle-time, not runtime.
Macros execute synchronously in the transpiler’s visit pass—after plugins, before generating the AST. They execute in import order. The transpiler waits for macros to execute before continuing. If a macro returns a Promise, the transpiler will also wait for it to complete.
Bun’s bundler is multi-threaded, so macros execute in parallel across multiple JavaScript “worker threads”.
Dead code elimination
After macros execute and are inlined, the bundler performs dead code elimination. For instance, this macro:
export function returnFalse() {
return false;
}
Bundling the following code (with syntax minification enabled) will result in an empty bundle:
import { returnFalse } from "./returnFalse.ts" with { type: "macro" };
if (returnFalse()) {
console.log("This code will be eliminated");
}
Serializability
Bun’s transpiler must be able to serialize a macro’s return value to inline it into the AST. All JSON-compatible data structures are supported:
export function getObject() {
return {
foo: "bar",
baz: 123,
array: [1, 2, { nested: "value" }],
};
}
Macros can be async or return Promise instances. Bun’s transpiler will automatically wait for the Promise and inline the result.
export async function getText() {
return "async value";
}
The transpiler implements special serialization logic for common data formats like Response, Blob, and TypedArray.
- TypedArray: Resolved to a base64-encoded string.
- Response: Bun reads the
Content-Type and serializes accordingly; e.g. application/json types are automatically parsed to objects, text/plain is inlined as a string. Unknown or undefined Response types are converted to base64.
- Blob: Like Response, serialization depends on its
type property.
fetch returns a Promise<Response>, which can be returned directly.
export function getObject() {
return fetch("https://bun.sh");
}
Functions and most class instances (except those mentioned above) are not serializable.
export function getText(url: string) {
// This won't work!
return () => {};
}
Arguments
Macros can accept inputs, but only in limited cases. Argument values must be statically known. For instance, the following usage is not allowed:
import { getText } from "./getText.ts" with { type: "macro" };
export function howLong() {
// The value of `foo` cannot be statically known
const foo = Math.random() ? "foo" : "bar";
const text = getText(`https://example.com/${foo}`);
console.log("The page is ", text.length, " characters long");
}
If the value of foo is known at bundle-time (e.g. it’s a constant or the result of another macro), then the following usage is allowed:
import { getText } from "./getText.ts" with { type: "macro" };
import { getUrl } from "./getUrl.ts" with { type: "macro" };
const foo = "bar"; // statically known
const url = getUrl(); // result of another macro
const text = getText(`https://example.com/${foo}`);
const data = getText(url);
Type safety
Macros are just regular functions, so they work seamlessly with TypeScript:
export function random(): number {
return Math.random();
}
import { random } from "./random.ts" with { type: "macro" };
const value: number = random(); // Type-checked!
Examples
Reading files at build time
export function readFile(path: string) {
return Bun.file(path).text();
}
import { readFile } from "./readFile.ts" with { type: "macro" };
const template = readFile("./template.html");
console.log(template);
Environment variables
export function getEnv(key: string) {
return process.env[key];
}
import { getEnv } from "./env.ts" with { type: "macro" };
const apiKey = getEnv("API_KEY");
console.log(`API key: ${apiKey}`);
Build-time calculations
export function factorial(n: number): number {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
import { factorial } from "./calculate.ts" with { type: "macro" };
const result = factorial(10);
console.log(`10! = ${result}`);
// Bundles to: console.log(`10! = ${3628800}`);
Fetching data at build time
export async function fetchData(url: string) {
const response = await fetch(url);
return response.json();
}
import { fetchData } from "./fetch.ts" with { type: "macro" };
const data = await fetchData("https://api.example.com/config");
console.log(data);
Generate code from database
import { Database } from "bun:sqlite";
export function getUsers() {
const db = new Database("./users.db");
const users = db.query("SELECT * FROM users").all();
db.close();
return users;
}
import { getUsers } from "./db.ts" with { type: "macro" };
const users = getUsers();
console.log(`Found ${users.length} users`);
export function getBuildInfo() {
return {
timestamp: Date.now(),
nodeVersion: process.version,
platform: process.platform,
arch: process.arch,
};
}
import { getBuildInfo } from "./build-info.ts" with { type: "macro" };
const buildInfo = getBuildInfo();
console.log(`Built on ${new Date(buildInfo.timestamp)}`);
Debugging macros
If a macro throws an error, the build will fail with a stack trace:
error: Macro failed
throw new Error("Something went wrong");
^
Error: Something went wrong
at getMacroValue (./macro.ts:2:9)
at ./app.ts:3:14
You can add console.log() statements to debug macros:
export function debug() {
console.log("Macro is running!");
return 42;
}
These logs will appear in your terminal during the build.
Limitations
- Macros cannot import files that are not yet processed by the bundler
- Circular dependencies between macros are not supported
- Macros cannot use dynamic imports (
import())
- Macro arguments must be statically analyzable
- Only serializable values can be returned
Macros run during the build, so expensive operations will slow down your build time:
- Cache results when possible
- Avoid unnecessary network requests
- Consider using build-time constants instead of macros for simple values
- Use
--no-macros in development if macros are slow
Comparison with other approaches
vs. Codegen scripts
Macros run inline with your code, automatically parallelize, and fail the build if they fail. Separate codegen scripts require manual orchestration.
vs. Build-time plugins
Macros are simpler—just import and call functions. Plugins require more setup but offer more control over the build process.
vs. Runtime code
Macros run at build time, so the results are inlined and the source code is not included in the bundle. Runtime code runs every time your app starts.