May 27, 2025

Exploring Metaprogramming in Mojo

Brian Grenier

This guest post is by Brian Grenier, a C++ developer working in cardiac image processing and an active contributor to the Mojo standard library. He maintains two open-source Mojo libraries: EmberJSON, a pure Mojo JSON parser, and Kelvin, a type-safe dimensional analysis toolkit. You can often find him sharing insights and answering questions in the Modular forum and Discord server (@eggsquad).

Mojo's parameter system is one of its most powerful and expressive features. In this post, I will take a closer look at a focused subset of Mojo's metaprogramming capabilities. For a breadth-first introduction to these features, please refer to the official Modular documentation.

Many of this article's examples include snippets of LLVM-IR to help illustrate what the Mojo compiler is doing under the hood. If you are not familiar with LLVM-IR, you can find a helpful language reference here.

Parameters for compile-time calculations

Metaprogramming is a technique widely used in many programming languages where we write code that modifies itself or other code. Python does this dynamically at runtime, whereas compiled languages like Rust, C++, and Mojo provide utilities for directing the compiler to generate and modify code at compile time. If you are already familiar with Rust generics or C++ templates, the Mojo parameter system will likely feel familiar.

A common use case for metaprogramming is creating generic functions and types; Python can do this implicitly via duck typing.  The Python interpreter is generally unconcerned with the type of a variable until it comes time to perform some operation on it, so as long as the type you provide to a function satisfies those definitions, everything will move along just fine.

Mojo
def doubler(a): return a + a b = doubler(2) c = doubler(1.245)

Without using generics, statically typed languages like Mojo need to rely on creating multiple definitions of that function in order to handle different input types:

Mojo
fn doubler(i: Int) -> Int: return i + i fn doubler(s: Float64) -> Float64: return s + s var a = doubler(2) var b = doubler(20.0)

We can solve the problem of duplicating functions like this by using generics, which in Mojo are implemented as part of the parameter system. In Mojo, the set of expected parameters for a struct or function is defined using a square bracket-enclosed list. All parameters must be types or expressions that are known at compile time. Mojo uses traits to inform the compiler about the kinds of types that a type parameter can bind to. Using traits, we can create an Addable trait like so:

Mojo
trait Addable: fn __add__(self, other: Self) -> Self: ...

With this in place, we can write a new doubler function that operates on both of these types by telling the compiler, "This function operates on all types that have the Addable trait and as a result, implement the __add__ method."

Mojo
@no_inline fn doubler[T: Addable](a: T) -> T: return a + a

By diving into the LLVM-IR output from the Mojo compiler, we can see that the compiler has done the work for us of generating a different function definition for each type:

LLVM
; Function Attrs: noinline define internal i64 "playground::doubler[playground::Addable]..."(i64 noundef %0) #1 { %2 = add i64 %0, %0 ret i64 %2 } ; Function Attrs: noinline define internal double @"playground::doubler[playground::Addable]..."(double noundef %0) #1 { %2 = fadd contract double %0, %0 ret double %2 }

Function parameters vs. arguments

Before we dive deeper, how is a “parameter” different from an “argument”? Mojo allows functions to define two different kinds of inputs: one category that must be known at compile time when a function is instantiated, and one that is allowed to be unknown until the function is called at runtime. We need words to reason about this so we say that “parameters” are compile time inputs to a function - you might say that functions can be “parameterized” - and that arguments are the runtime inputs to the function.

For example, this function takes width as a parameter which must be known at compile time.  It is used in the type of arg1 and arg2 (which are known at runtime), and supports math like multiply:

Mojo
fn take_floats[width: Int](arg1: SIMD[DType.float16, width], arg2: SIMD[DType.float16, width*2]): constrained[width > 4]() ... def main(): take_floats(SIMD[DType.float16, 1](), SIMD[DType.float16, 2]())) # compile error, width is not > 4

Parameter values are always immutable, and can be further used in other compile-time expressions. Note the use of width in a comparison expression against 4 in the constrained function call. constrained allows us to make compile-time assertions (like static_assert in other languages). In this case, we arbitrarily assert that the value of width is always greater than four.

Parameter values can also "hop the fence" into the runtime domain–observe how the constant 30 has been dropped directly into the multiplication expression in the IR output:

Mojo
@no_inline fn multiplier[m: Int](a: Int) -> Int: return m * a # "m" is a compile time value used at runtime. def main(): print(multiplier[30](2))
LLVM
define internal i64 @"playground::multiplier[::Int](::Int),m=30"(i64 noundef %0) #1 { %2 = mul i64 %0, 30 ret i64 %2 }

The alias keyword

Mojo provides a very powerful alias keyword that allows us to create new names for types, as well as define compile-time constant values. In the latter case, alias functions as the compile-time equivalent of var for runtime variables. Since values defined using alias are known at compile time, they can also be used in a parameter list:

Mojo
alias IntList = List[Int] alias MyConstant = 5 multiplier[MyConstant](10)

Looking at the IR output, we can see the value of MyConstants has been dropped directly into the expression:

LLVM
define internal i64 @"playground::multiplier[::Int](::Int),m=5"(i64 noundef %0) #1 { %2 = mul i64 %0, 5 ; <- The input value being multiplied by our constant 5 ret i64 %2 }

Thanks to Mojo's compile-time interpreter, you can use alias to precompute non-trivial expressions as well, which enables offloading runtime compute to the compiler:

Mojo
fn fib(n: Int) -> Int: if n <= 1: return n return fib(n - 1) + fib(n - 2) fn main(): alias f = fib(30) print(f)

And just like that, this function call has been folded into a store instruction:

LLVM
define dso_local i32 @main(i32 noundef %0, ptr noundef %1) #0 { %3 = alloca i64, i64 1, align 8 ... store i64 832040, ptr %3, align 8 ; <- the output of fib(30) ... }

When using alias , Mojo guarantees the expression will be completely compile-time folded. Any instructions that are not compile-time computable (such as special hardware intrinsics) will result in compilation failure.

You can even create an alias to a parameterized function, referring back to our multiplier function–we can create a function that always multiplies the input by two:

Mojo
alias doubler = multiplier[2] def main(): print(doubler(2))

The compiler is also sometimes capable of entirely eliding the runtime materialization of a compile-time variable. For instance, if you create an alias to an InlineArray and index it using a compile-time-known value, the compiler will simply replace that array access with the appropriate value:

Mojo
alias l = InlineArray[Int, 3](45, 34, 32) @no_inline fn get_val() -> Int: return l[1] fn main(): print(get_val())
LLVM
define internal i64 @"playground::get_val()"() #1 { ret i64 34 }

Compile-time branching

Through the power of parameter values, we can even make branching decisions at compile time by using @parameter if statements:

Mojo
@no_inline fn foo[n: Int]() -> String: @parameter if n < 0: return "Negative number" else: return "Positive number" fn main(): print(foo[1]()) print(foo[-1]())

We can see in the IR that the if statement has been folded into a constant value branch. Mojo guarantees unreachable code is not compiled into the final binary. So this is effectively equivalent to an #ifdef expression in C/C++, but much more powerful, including the added benefit of both branches being type checked. Meaning that developers writing code that targets multiple devices, such as GPUs and CPUs, have type safety through the entire codebase. Regardless of which platform they are currently compiling for:

LLVM
define internal { ptr, i64, i64 } @"playground::foo[::Int](),n=-1"() #1 { ... br i1 true, label %6, label %9 ...

More recently, the is_compile_time function was added to the Mojo standard library, which allows invoking different behavior at compile time and runtime:

Mojo
@no_inline fn foo() -> String: if is_compile_time(): return "CTime" else: return "Runtime" fn main(): alias s = foo() print(s) print(foo())

This is generally useful to provide fallback implementations of functions that would otherwise invoke platform-specific instructions which cannot be handled by the MLIR interpreter, or other expressions that don't have meaning at compile time, such as this example from the Mojo standard library:

LLVM
@always_inline("nodebug") fn assume(val: Bool): """Signals to the optimizer that the condition is always true. This allows the optimizer to optimize the code. Args: val: The input value which is assumed to be `True`. """ if is_compile_time(): return llvm_intrinsic["llvm.assume", NoneType](val)

In this case, the interpreter would throw an error upon trying to evaluate llvm_intrinsic["llvm.assume", NoneType](val), so we ignore it completely in that case.

Loop unrolling

Loop unrolling is an optimization technique used by compilers to save the overhead of incrementing loop variables and handling branch instructions. In performance-critical applications, this is even done manually by the programmer. In Mojo, we can ensure that the compiler always unrolls a particular loop using the @parameter decorator:

Mojo
var a = 0 var l = List(1, 2, 3) for i in range(3): a += l[i] # manually unrolled version var a = 0 var l = List(1, 2, 3) a += l[0] a += l[1] a += l[2] # @parameter for var a = 0 var l = List(1, 2, 3) @parameter for i in range(3): a += l[i]

In the IR, we can see the loop has been inlined:

LLVM
%35 = load i64, ptr %19, align 8 %36 = load i64, ptr %6, align 8 %37 = add i64 %36, %35 ; <- a += l[0] store i64 %37, ptr %6, align 8 %38 = getelementptr inbounds i64, ptr %19, i32 1 %39 = load i64, ptr %38, align 8 %40 = load i64, ptr %6, align 8 %41 = add i64 %40, %39 ; <- a += l[1] store i64 %41, ptr %6, align 8 %42 = getelementptr inbounds i64, ptr %19, i32 2 %43 = load i64, ptr %42, align 8 %44 = load i64, ptr %6, align 8 %45 = add i64 %44, %43 ; <- a += l[2] store i64 %45, ptr %6, align 8

Dependent types

As the name suggests, "dependent types" are types whose definition depends on a value. For a concrete example, let’s take a look at a struct backed by Int:

Mojo
@value @register_passable("trivial") struct Adder[I: Int]: @always_inline('builtin') fn __init__(out self): pass @always_inline('builtin') fn __add__[OI: Int, //](self, other: Adder[OI]) -> Adder[I + OI]: return {}

Adder takes a value parameter I ; therefore, the particular value of I is now a part of the type definition of Adder . Note in the definition of Adder.__add___() that we use this property to enforce the compile-time evaluation of the addition by encoding it into a type system transformation. The @always_inline('builtin') decorator is used to ensure that the method body can be fully compile-time folded so expressions like Adder[1]() + Adder[3]() are in this case exactly equal to Adder[4](). Refer to the feature proposal for a more in depth explanation.

Encoding information in this way is also useful for enforcing that a function argument is parameterized with a particular value:

Mojo
fn must_add_two[AI: Int, //](a: Adder[AI], b: Adder[2]) -> Adder[AI + 2]: return a + b fn main(): print(must_add_two(Adder[10](), Adder[2]()).I) # print(must_add_two(Adder[10](), Adder[3]()).I) # Compilation error

Since the addition operation has been moved into the type system, the resulting value I is simply materialized into a runtime store instruction of the value 12:

LLVM
store i64 12, ptr %3, align 8

Representing complex information in the type system

To show a more complex example of what we can represent in a parameter, we're going to dive into a library I wrote called Kelvin. It is a type-safe dimensional analysis library in which all the safety checks are done at compile time to ensure minimal performance overhead over raw numbers. Let's explore how we can represent "10 meters per second" in code.

First, we need a definition for a physical "dimension" – here we define a struct Dimension that has 3 parameters:

  1. Z: The value of the dimension, eg. the value of Z for the length dimension of area would be 2
  2. R: The scale of the dimension. This is used to differentiate between different scales of the same unit, eg. Meters vs Kilometers
  3. suffix: The string suffix for the value, e.g. km, K, mol, etc

We also have a distinct Angle struct since angle isn't a true dimension and behaves differently:

Mojo
struct Dimension[Z: IntLiteral, R: Ratio, suffix: String](...): ... struct Angle[R: Ratio, suffix: String](./..): ...

The Mojo parameter system allows us to write functions where the type of the return value is dependent on the function inputs. This allows us to build the following example where we define methods for Dimension that subtract the Z value of an input dimension, and return a new dimension type parameterized:

Mojo
@always_inline("builtin") fn __sub__( self, other: Dimension ) -> Dimension[Z - other.Z, R | other.R, suffix or other.suffix]: return {}

This behavior empowers us to do some cool stuff later on.

As another example, there are 7 dimensions in the SI unit system, so we can compose 7 dimension units plus an angle unit to create the Dimensions of a value:

Mojo
struct Dimensions[ L: Dimension, # Length dimension. M: Dimension, # Mass dimension. T: Dimension, # Time dimension. EC: Dimension, # Electric current dimension. TH: Dimension, # Temperature dimension. A: Dimension, # Substance amount dimension. CD: Dimension, # Luminosity dimension. Ang: Angle, # The angle component of the quantity. ](...): ...

Now we can define the result of dividing dimensions using the __sub__ method we just created. Units that live in the denominator are defined as negative Z values, e.g. m / s is represented by m^1 s^-1:

Mojo
@always_inline("builtin") fn __truediv__( self, other: Dimensions ) -> Dimensions[ L - other.L, M - other.M, T - other.T, EC - other.EC, TH - other.TH, A - other.A, CD - other.CD, Ang.pick_non_null(other.Ang), ]: return {}

With that plumbing in place, we can define a Quantity struct that represents our actual value:

Mojo
struct Quantity[D: Dimensions, DT: DType = DType.float64, Width: UInt = 1](...) alias ValueType = SIMD[DT, Width] var _value: Self.ValueType ...

Now we can define particular units easily using alias:

Mojo
alias Meter = Quantity[ Dimensions[ Dimension[1, Ratio.Unitary, "m"](), Dimension.Invalid, Dimension.Invalid, Dimension.Invalid, Dimension.Invalid, Dimension.Invalid, Dimension.Invalid, Angle.Invalid, ](), _, _, ] alias Second = Quantity[ Dimensions[ Dimension.Invalid, Dimension.Invalid, Dimension[1, Ratio.Unitary, "s"](), Dimension.Invalid, Dimension.Invalid, Dimension.Invalid, Dimension.Invalid, Angle.Invalid, ](), _, _, ]

Since we've defined a Dimensions / Dimensions operation, we can now define derivative units such as velocity in a single line of code:

Mojo
alias MetersPerSecond = Quantity[Meter.D / Second.D, _, _]

Using the power of compile-time programming, we can now define operations on Quantity that respect the rules of the SI unit system, which are all verified at compile time. Starting with a simple example (since addition/subtraction only makes sense between the quantity of matching dimensions), we can use the special Self type to ensure that self and other are the exact same type:

Mojo
@always_inline fn __add__(self, other: Self) -> Self: return Self(self._value + other._value) var s1 = Second(10) var s2 = Second(30) var s3 = s1 + s2 # All good var bad = s1 + Meter(20) # compiler error

Things get a bit more complex for multiplication and division since those operations are allowed on non-matching units, and the return type is dependent on those input types - we'll take division as our example here:

Mojo
@always_inline fn __truediv__[ OD: Dimensions ](self, other: Quantity[OD, DT, Width]) -> Quantity[D / OD, DT, Width]: _dimension_scale_check[D, OD]() return {self._value / other._value}

Here at compile-time, we have a static assertion that the dimensions of the two types have matching scale, e.g. meters per second, and miles per hour have matching dimensions, but are incompatible due to differences in unit scale. In the output argument, we compute the derivative dimensions using D / OD; therefore, the output of the method is the exact type we would expect from this operation:

Mojo
var s = Second(10) var m = Meter(20) var v: MetersPerSecond = m / s

Inspecting the IR, we can see all this parameter complexity has been resolved, and all that remains is a simple division operation:

LLVM
%3 = fdiv contract double %1, %0

Using this power responsibly

Understanding exactly how these abstractions work is critical to using them effectively; for instance, using a @parameter for loop will always result in the entire loop being unrolled, potentially resulting in binary bloat and negative performance implications:

LLVM
@parameter for i in range(1000000): print(i)

Wrapping up

Mojo offers powerful tools for executing code seamlessly at compile time, allowing programmers to build powerful abstractions into the type system. This completely removes the associated complexity from runtime, and progress is continuously made (such as parameteric aliases).

I hope you found this article helpful in understanding how to use these features, and feel free to reach out on Discord (@eggsquad) or the Modular forum (@bgreni) if you have any questions! Head over to my GitHub (@bgreni) to follow my progress on Kelvin and other Mojo libraries I work on.

Brian Grenier
,