API Stability Notice

Macroforge is under active development. The API is not yet stable and may change between versions. Some documentation sections may be outdated.

Expanded Config

Foreign-type handlers are written in macroforge.config.ts, but they run inside the code @derive(Encode) and @derive(Decode) generate in your own modules. Macroforge connects the two by expanding the config into .macroforge/config/, where every handler is a named export, and generated code imports the handlers it calls from #macroforge/config.

Before and after

Take a config whose handlers use a library and a helper of its own:

// macroforge.config.ts
import { DateTime } from "effect";

const isIsoString = (value: unknown): value is string =>
  typeof value === "string" && !Number.isNaN(Date.parse(value));

export default {
  foreignTypes: {
    "DateTime.Utc": {
      from: ["effect"],
      encode: (v: DateTime.Utc) => DateTime.formatIso(v),
      decode: (raw: unknown) => DateTime.unsafeFromDate(new Date(String(raw))),
      default: () => DateTime.unsafeNow(),
      hasShape: isIsoString,
    },
  },
};

and a type that uses it:

// event.ts
import type { DateTime } from "effect";

/** @derive(Encode, Default) */
export interface Event {
  name: string;
  startsAt: DateTime.Utc;
}

Before: handler bodies were copied

Each handler’s source text was pasted into every generated module, and the names it read were patched up with synthetic imports:

// event.ts, expanded
import { DateTime as __mf_DateTime } from "effect";

export function eventEncodeWithContext(value: Event, ctx: __mf_EncodeContext) {
    // ...
    result.startsAt = ((v) => __mf_DateTime.formatIso(v))(value.startsAt);
}

export function eventDefaultValue(): Event {
    return { name: "", startsAt: (() => __mf_DateTime.unsafeNow())() };
}

That only works while a handler reads nothing but imports macroforge can guess at. hasShape: isIsoString copies as the bare name isIsoString, which the expanded module never defines, so it fails at runtime with a ReferenceError. The same happens to any helper, constant or local function the config declares.

After: handlers are imported

The config is expanded into .macroforge/config/handlers.ts, with each handler hoisted to an export named after its type:

// .macroforge/config/handlers.ts (generated)
import { DateTime } from "effect";

const isIsoString = (value: unknown): value is string =>
  typeof value === "string" && !Number.isNaN(Date.parse(value));

export const __foreign__dateTimeUtcEncode = (v: DateTime.Utc) => DateTime.formatIso(v);
export const __foreign__dateTimeUtcDecode = (raw: unknown) => DateTime.unsafeFromDate(new Date(String(raw)));
export const __foreign__dateTimeUtcDefault = () => DateTime.unsafeNow();
export default {
  foreignTypes: {
    "DateTime.Utc": {
      from: ["effect"],
      encode: __foreign__dateTimeUtcEncode,
      decode: __foreign__dateTimeUtcDecode,
      default: __foreign__dateTimeUtcDefault,
      hasShape: isIsoString,
    },
  },
};

export { isIsoString as __foreign__dateTimeUtcHasShape };

Generated code imports what it calls:

// event.ts, expanded
import { __foreign__dateTimeUtcEncode, __foreign__dateTimeUtcDefault } from "#macroforge/config";

export function eventEncodeWithContext(value: Event, ctx: __mf_EncodeContext) {
    // ...
    result.startsAt = __foreign__dateTimeUtcEncode(value.startsAt);
}

export function eventDefaultValue(): Event {
    return { name: "", startsAt: __foreign__dateTimeUtcDefault() };
}

Every handler now runs in the module it was written in, so it can use anything that module imports or declares. Each is a separate export, so a bundler keeps only the handlers your code calls.

Setup

#macroforge/config is a subpath import, declared in your manifest the way SvelteKit declares #lib. Add it once:

macroforge init

which writes this into package.json (or the imports map of deno.json when there is no package.json):

{
  "imports": {
    "#macroforge/config": {
      "types": "./.macroforge/config/handlers.ts",
      "default": "./.macroforge/config/handlers.js"
    }
  }
}

TypeScript, bundlers, Node and Deno all resolve it natively. A project whose config declares foreign types but whose manifest does not map #macroforge/config fails with an error that says what to add. macroforge init does not rewrite a deno.jsonc, since that would drop its comments; add the entry by hand there.

When it is regenerated

.macroforge/config/ is generated, like .svelte-kit/: don’t edit it and don’t commit it. It is rewritten whenever macroforge reads the project: the Vite plugin’s project scan, macroforge watch (again on every config change), macroforge cache and macroforge expand. In CI, run macroforge sync before a type check to write it without expanding anything. It is written only when its contents change, and swapped in whole, so a reader never sees half of it.

Both handlers.ts and a type-stripped handlers.js are written, so the entry resolves in tools that read types and in runtimes that don’t. A config that extends a base config gets the base expanded beside it under modules/, with its handlers re-exported from handlers.ts.

What the config may contain

The expanded config is imported by generated code, so it runs wherever your code runs. The expansion therefore refuses a config that:

  • runs a statement at its top level, such as a bare function call: the top level may only declare;
  • uses import.meta or a dynamic import(), which read differently once the module is moved into .macroforge/config/;
  • declares a foreign type inside a function, where its handlers cannot be exported;
  • gives two foreign types the same export name.

Relative imports in the config are rewritten to reach the same files from .macroforge/config/. Calls that initialise a top-level binding or the default export are marked /*#__PURE__*/, so a bundler can still drop what is unused.

Packaged libraries

A published package does not ship .macroforge/. When macroforge svelte-package packages a library, it copies the expanded config into the output as __macroforge/config/ and rewrites each emitted #macroforge/config import to a relative path to that copy, so the package needs no manifest entry of its own.