osa1 github about atom

Macros in Fir

May 12, 2026 - Tagged as: en, plt, fir.

Fir macros are fully deterministic programs that are distributed separately and that can introspect into the using program’s type-checked AST definitions.

Deterministic execution of macros is necessary to be able to

Separate distribution is not a requirement but it simplifies the implementation quite significantly, and it’s also a good idea from a software design point of view:

It should be rare for a macro and a library to recursively depend on each other. In these rare cases, the library can be split into two smaller libraries: one that the macro uses, another one that uses the macro (and maybe also the first library).

Introspection makes macros much more flexible and useful for many use cases. Without introspection macros take ASTs (or token trees, or any code as a string) as arguments and need to be passed the ASTs directly. E.g. in Rust, derive macros need to be added as an attribute to the definitions as they can’t otherwise go look up definition of a type that you pass to it as an identifier.

#[derive(MyDeriveMacro)]
type Foo { ... }

Here MyDeriveMacro is passed the token trees of the next item (the type definition). This has annoying limitations:

In Fir macros, you can pass explicitly (this part is important) as many type and function identifiers as you want and the macro gets the full type-checked ASTs of the definitions of those types and functions. These type-checked ASTs also allow looking up definitions used by those types and functions, so you also get the dependencies of those types and functions.

Short intro to the syntax before moving on to examples: $ indicates a macro call, @ is the syntax for passing a definition (rather than token tree) to a macro. E.g. @foo(@MyType, @myFunction, some [other, random = ("tokens")]) passes the full type-checked ASTs of MyType and myFunction to the macro, and an untyped token tree for the third argument.

A few examples of what introspection allows:

Introspection opens up so many possibilities and solves many of the problems with purely syntactic macros (that take just a string, list of tokens, or ASTs as arguments).

Interaction with type checking

Fir has been designed from day 1 for parallel type checking and compilation.

With macros that can be passed type-checked ASTs, type checking gets interleaved with macro expansion, but module-level parallelism is not affected. This is because macros can’t generate imports and generated code (same as hand-written code) can’t access definitions that are not imported. (e.g. Fir doesn’t have paths like Rust’s crate::... or package::..., you can use names with qualified paths, but the paths still need to be imported explicitly first)

So we process the modules the same way as before: starting from the main module (or public modules in a library) we create a dependency DAG of modules1. This DAG can then be processed in parallel as before. Macros have no influence over the DAGs of modules.

Within a module though, things get a bit tricky. A macro call can only be expanded after the definitions it introspects into are fully type checked, but it also needs to be expanded as soon as possible as it may generate definitions that other definitions use.

To deal with the first part of the problem (determining macro dependencies), we require that definitions are passed to macros explicitly (with the @<identifier> syntax we used above). If a definition is not explicitly passed to a macro and its not a dependency of a definition that’s explicitly passed to it, the macro won’t have access to it.

However, determining macro outputs ahead of time is not possible. So to deal with the second part of the problem, we expand macros as soon as their dependencies are type checked. This creates a schedule of type checking and macro expansion operations in each module. Name resolving pass creates a dependency DAG of module-level items. Macros are also a part of these DAGs and their dependencies are determined by the @...s in their arguments. When there’s an unbound name in a definition, that name is potentially generated by the macros in the module that don’t depend on the definition, so that creates dependencies from the item with unbound names to those macros (that don’t depend on the definition) in the module. Macros can’t be in a recursive dependency group (SCC) with other macros or definitions, so in the DAG we require that each macro is in its own group.

When we process this DAG of type checking and macro expansion operations in topological order we type check macro definitions before macro expansion, and expand macros before any potential dependencies on their expansions.2

Macro call locations are not important for this algorithm, as the definitions are not processed in source code order. You can put a macro call anywhere in a module and it works the same way.

Interaction with the trait environment

Macros can generate traits and impls, but they don’t have access to the trait environment and can’t introspect into traits and impls:

Deterministic execution of macros

This is not enforced in the current prototype, but it will be in the final version.

Once the effect system is ready, we can require that a function needs to have no effects to be usable as a macro.

However in any kind of statically checked system there will always be escape hatches (for the system to be practically useful), so just compile-time/type-level enforcement won’t be enough, and we’ll need to sandbox the macro programs regardless of how/what we check in compile time.

One easy way here would be compiling them to Wasm and then making host calls for IO (and other things we don’t allow in macros) fail. This is easy to implement but it requires a Wasm engine to be embedded within the language front-end, and execution will be slower than a native executable as (1) Wasm will need to be interpreted or JIT compiled (2) a native library could be loaded dynamically and it can share the same address space, so we can share immutable references to type-checked ASTs with the macros instead of serialization and deserialization for ASTs as they’re passed to macros and the generated ASTs are returned to the language front-end.

The details here are to be determined.

The macro API

In the prototype, macros are a part of the implementation and they use the internal data structures of the compiler.

One of the other goals with Fir since the early days is to have the language front-end available to users as libraries. To avoid creating yet another library/API when we already have the language front-end available, the macros will probably use the language’s official AST library.3

To avoid passing large ASTs to macros when a macro only needs the main type being passed (without the dependencies), we allow back-and-forth between a macro and the language front-end. A macro will be able to request ASTs of dependencies of the main AST being passed, it won’t get the whole thing in one call.

Macros will only have access to the definitions they’re explicitly passed (with the @<identifier> syntax) and won’t be provided anything else other than the passed definition and its dependencies, even if it so happens that at the time of expansion we type checked more. This is a part of the determinism requirements: given same inputs macros should always generate same outputs. Location of the macro call or type checker internals (or order) should not matter and should not change macro expansion.

Quotation in macros will be implemented using macros, e.g. when generating an expression instead of generating the ASTs manually:

Expr.BinOp(
    left = Expr.Var(...),
    op = Binop.Add,
    right = Expr.Call(...),
)

We implement (and distribute as a part of the language) quotation macros and instead have:

$expr(var + f(...))

$expr here is macro that parses its arguments (token trees) and converts them to Fir AST expressions.

(This is the same idea as Rust’s quote package.)

We can pass typed-checked ASTs and token trees, but not parsed ASTs (not type checked). I’m not sure how useful this would be, but if it becomes useful we can easily extend the system to allow passing parsed ASTs to macros. E.g. maybe we use @@expr[...] parsing an inlined expression and passing it to the macro as an expression AST.

In the meantime, macros can just parse the token trees as whatever they want using the language’s libraries. (instead of expecting parsed inputs)

Macro functions are ordinary Fir functions with a particular signature, but their signature allows passing different number of arguments with different types (token trees, type identifiers, function identifiers). The idea is that the same macro function can handle multiple call patterns, as in the deriveEq example above:

The function signature for this macro looks something like:

deriveEq(inputs: Vec[TokenTree]) Ast: ...

Where TokenTree is a sum type with actual token trees but also type and function identifiers, and Ast is a sum type that has constructors for top-level items, expressions, statements, and anything else that we allow macros to generate.

The reasons for this design are:

By allowing arbitrary sequence of (potentially comma separated) token tree in the argument lists we allow this flexibility and let the macro do sanity checking for the arguments.

Conditional compilation

As mentioned in the intro, it’s a deliberate design goal with macros that they’re fully deterministic and they generate the same code for the same inputs regardless of the compilation settings (host or target platforms, optimization parameters, etc.).

Conditional compilation in Fir will be done by dedicated language features (that don’t exist today). Macros will be able to generate code that use those conditional compilation features, but they won’t be doing conditional compilation themselves.

For example, if we have a syntax for checking target architecture pointer size, macros won’t be able to use it but they will be able to generate code that use the syntax for checking the target architecture pointer size.

This doesn’t complicate the macro system implementation any more than it already is: as mentioned, we need to sandbox macros anyway (or somehow make sure in compile time that they don’t have access to certain APIs). We just prevent access to conditional compilation features in similar ways.

Hygiene

Macro-generated code is name resolved and type checked in the using module’s environment, and so it can refer to names available at the macro call site.

To avoid issues when a call site imports e.g. the standard library with a prefix and the macro generates references to the standard library types, macros should generate fully qualified names. E.g. Fir/Vec/Vec[U32] instead of just Vec[U32]. However this is not enforced.

For the cases when a macro generates type or term ids that shouldn’t shadow definitions at the call site (either in the macro-generated code, or the code around the macro expansion), we provide a gensym function in the standard library. This function is only accessible by macros.

ASTs of types and terms passed to the macros (with the @<identifier> syntax) already have name-resolved ASTs, and using parts of those ASTs in the outputs generate qualified names that can’t be shadowed at the call sites. This is not done via a magic AST node that can only be created by the language front-end: identifiers in the ASTs can have qualifications or prefixes and that’s how macros should generate qualified names whenever possible. When we pass a @MyType to a macro and MyType uses Vec, the Vec references in the AST of MyType will have fully qualified path to the Vec. So copying that into the output also gives us a fully qualified Vec reference that we could also hand write.

Being able to write the fully qualified path Foo/Bar/Baz or macro-generate it doesn’t mean we can avoid importing Foo/Bar. It’s an explicit goal of Fir modules that the dependencies are always fully specified in the imports, and while we can access a definition in more than one way (with fully qualified paths, directly using the imported name, we can also import the same definition under different prefixes or with different names), there’s no way to access a definition without importing a module that exports it.

Macros don’t change this fact. They should always generate fully qualified paths to avoid shadowing and depending on modules being imported in a particular way, but the references in the generated code should still be explicitly imported by the calling module. This may mean that in some cases a call site of a macro may need to add imports that look unused, because the imported things are used in macro expansions.

The principle here is that macros can’t generate code that you can’t write by hand.

Final thoughts and current status

Unlike the other blog posts about Fir, features here are not fully implemented. The parts until the deterministic execution section above are currently implemented in a prototype and working.

The type checker requires quite a lot of refactoring for the proper implementation, which I’m slowly working on.

I think this is the final feature Fir needs to be considered a proper language, ready to tackle real problems. Once done, we’ll focus on bootstrapping the language.


  1. Fir allows recursive module imports, so it’d actually be more accurate to say “dependency DAG of SCCs of modules”. To keep things simple in this discussion we can assume modules can’t be recursive.↩︎

  2. There’s an edge case here that we don’t deal with and let things fail to type check: when a macro generates e.g. foo but there’s also an imported foo, definitions in the module that use foo can use either the imported foo (if they’re scheduled before the macro expansion) or the macro-generated foo (if they’re scheduled after the macro expansion, because local definitions shadow imported ones). This case should be extremely rare and it’s not worth complicating the design or implementation more to deal with this.↩︎

  3. The library will probably provide different ASTs for parsed and type checked programs, which is easy to do in Fir thanks to extensible named types.↩︎