Skip to main content
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.
random.ts
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:
cli.tsx
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.
bun build ./cli.tsx
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.
cli.tsx
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.
package.json
{
  "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:
returnFalse.ts
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:
macro.ts
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.
macro.ts
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.
macro.ts
export function getObject() {
  return fetch("https://bun.sh");
}
Functions and most class instances (except those mentioned above) are not serializable.
macro.ts
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:
random.ts
export function random(): number {
  return Math.random();
}
cli.ts
import { random } from "./random.ts" with { type: "macro" };

const value: number = random(); // Type-checked!

Examples

Reading files at build time

readFile.ts
export function readFile(path: string) {
  return Bun.file(path).text();
}
app.ts
import { readFile } from "./readFile.ts" with { type: "macro" };

const template = readFile("./template.html");
console.log(template);

Environment variables

env.ts
export function getEnv(key: string) {
  return process.env[key];
}
app.ts
import { getEnv } from "./env.ts" with { type: "macro" };

const apiKey = getEnv("API_KEY");
console.log(`API key: ${apiKey}`);

Build-time calculations

calculate.ts
export function factorial(n: number): number {
  if (n <= 1) return 1;
  return n * factorial(n - 1);
}
app.ts
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

fetch.ts
export async function fetchData(url: string) {
  const response = await fetch(url);
  return response.json();
}
app.ts
import { fetchData } from "./fetch.ts" with { type: "macro" };

const data = await fetchData("https://api.example.com/config");
console.log(data);

Generate code from database

db.ts
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;
}
app.ts
import { getUsers } from "./db.ts" with { type: "macro" };

const users = getUsers();
console.log(`Found ${users.length} users`);

Inline build metadata

build-info.ts
export function getBuildInfo() {
  return {
    timestamp: Date.now(),
    nodeVersion: process.version,
    platform: process.platform,
    arch: process.arch,
  };
}
app.ts
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:
macro.ts
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

Performance considerations

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.

Build docs developers (and LLMs) love