Login
📚 The Mojo Manual
Chapters ▾
⇩ Download ▾

5.6 Comptime constraints and assertions

Mojo's constraint system lets you express program guarantees that go beyond what the type system provides. This page explains how to use this system effectively.

A constraint, defined with the where keyword, represents a precondition for calling a function or instantiating a struct:

def pow2[n: Int]() -> Int where n >= 0:
    ...

Here the pow2 function requires its n parameter to be greater than or equal to 0. The expression n >= 0 is called a proposition. With the exception of very simple expressions, Mojo doesn't evaluate these propositions literally. Instead, it analyzes them symbolically, tracking a list of propositions that are known to be true in the current scope. Understanding this system of symbolic propositions is key to using constraints effectively.

Mojo also supports compile-time assertions, which test a proposition at compile time—if the assertion evaluates to false, compilation fails:

comptime assert x >= 0, "x must be greater than or equal to 0."

Defining constraints

You can use constraints in the following contexts:

Symbolic propositions

The constraint system works with symbolic propositions.

def first[size: Int](array: InlineArray[_, size]) -> array.T where size > 0:
  ...

In this case, size > 0 is a proposition that the constraint system tracks.

By analogy, when you annotate a type on an argument you ask the compiler to ensure callers only pass that type. When you annotate a constraint on a function, you ask the compiler to ensure that the proposition is true at the call site.

But the compiler can't test every proposition at every call site without running or interpreting unbounded amounts of code, which would vastly expand compile times. Instead, callers need to explicitly introduce "knowledge" into the constraint system.

Introducing knowledge

The compiler tracks knowledge by scopes: each scope contains a set of known true propositions, also known as "knowledge," and nested scopes accumulate knowledge from outer scopes.

There are four ways to introduce knowledge to the system:

Satisfying constraints with knowledge

Any time you call a function that has constraints, the system inspects the set of known-true propositions at the call site and determines whether the known set of propositions is a superset of the required set of propositions declared on the callee.

The constraint system treats all propositions symbolically: it doesn't know or care what the expression means and doesn't interpret the code. With a few very simple exceptions (discussed later), the system doesn't perform symbolic math on your behalf. Instead, it treats these propositions as opaque and only uses knowledge you've explicitly introduced in the calling scope.

# A function that wants a non-empty list.
def print_first[size: Int](l: InlineArray[Int, size]) where size >= 1:
    ...


# A wrapper that wants a size >= 2 list.
def print_first_two[size: Int](l: InlineArray[Int, size]) where size >= 2:
     # Error: invalid call to 'print_first': lacking evidence to prove
     # correctness
    print_first[size](l)
    # ...

Mojo checks constraints without knowing all the call sites, and these expressions can reference symbolic parameter values-for example is_prime(x) where x is unknown. Because of this, Mojo can't interpret is_prime for all possible values of x, nor can it make logical deductions. For example if is_prime(x) and x > 2 are true, it can't deduce that is_odd(x) must also be true.

Compile-time assertions

Use comptime assert to introduce a known-true proposition at a specific point in the code:

comptime assert x > 0, "x must be greater than 0."

The message is optional. If the condition evaluates to false at compile time, compilation fails and the compiler shows the message (or a generic message if none is specified).

Mojo adds the asserted condition to the list of "known true" propositions for any code following the assertion.

Constraints and assertions serve complementary roles: a where clause exposes a requirement that callers must prove, and comptime assert is one way to satisfy that requirement.

Limited evaluation of propositions

In general, it's safe to assume that the constraint system doesn't evaluate propositions directly, and that all knowledge needs to be provided explicitly. However, the constraint system does apply a very limited amount of "smartness" for very common cases. These are provided as a convenience and should not be treated as the norm.

The goal is to provide a consistent, predictable experience so users can recognize these patterns as they become more familiar with the system.

Simple implication

A known proposition of the form A and B can satisfy a requirement of A by itself (or B by itself), even though symbolically they aren't identical propositions.

def create_list[T: Copyable, size: Int]() -> List[T] where size >= 0:
    return List[T](capacity = size)

def create_even_list[T: Copyable, size: Int]() -> List[T] where (
    size >= 0 and (size / 2) * 2 == size
):
    # No need to individually prove `size >= 0` — it's part of the
    # `and` above, so the call to `create_list` type-checks.
    return create_list[T, size=size]()

def main():
    var l1 = create_even_list[Int, 4]()
    print(len(l1) % 2)
    # Prints 0, since the list has an even number of elements.

    # var l2 = create_even_list[Int, 5]()  # Won't compile: 5 isn't even.
Show output ✓ Verified · Mojo 1.0.0b2 (2cf4d08a)
0

Similarly, A implies A or B for any B.

Canonicalization

Sometimes there is more than one way to write the same expression. The system always simplifies expressions into a normal form internally, so expressions that aren't identical on the surface may be seen as identical by the system.

The following are self-explanatory (assume x: Int):

There's a special case for function calls. When invoking functions as part of a proposition, the system treats the entire function call as opaque. Only two identical function calls are the same.

def is_even(x: Int) -> Bool:
  return x % 2 == 0

def needs_even[x: Int]() where is_even(x):
  pass

def forward_even_bad[x: Int]() where x % 2 == 0:
  # ERROR: Needs evidence for `is_even(x)`.
  needs_even[x]()

def forward_even_good[x: Int]() where is_even(x):
  # SUCCESS
  needs_even[x]()

Context-free folding

This is more of an extreme case of canonicalization than a separate category, but may catch people by surprise.

Certain primitive operations on constants can be canonicalized into a new constant. For example:

This extends to expressions with a mix of constants and non-constants:

Concretely, this means you may omit explicitly providing knowledge for propositions that are simple operations on constants:

def create_my_list() -> List[2]:
  # No need to provide evidence for `2 >= 0`.
  return create_list[2]()

Best practices

The examples here focus on functions (declaring constraints when writing functions and satisfying constraints when calling functions), but the tips generalize to other forms of constraints such as struct parameter constraints and conditional trait conformances.

Writing functions

When writing a function, how do you decide whether a where clause is right for you?

Diagram: images metaprogramming constraint decisions

Q1: Does your function handle the entire domain of its input types?

If you can write your function so that it returns a result or throws an error on all inputs, you don't need to care about constraints at all. Just write your function as usual.

For example, the following functions do not need constraints since they're guaranteed to return or throw:

def create_list_opt[size: Int]() -> Optional[List[size]]:
  comptime if size < 0:
    return None
  return ...

def create_list_raise[size: Int]() raises -> List[size]:
  comptime if size < 0:
    raise Error("negative size!")
  return ...

Constraints are only for cases where you don't want to check for exceptional cases at execution time.

Q2: Is this limitation a central concept of your code?

If the condition represents a concept that is central to your code, a dedicated type may be easier than sprinkling where everywhere.

Examples:

This is a good approach because the constraint is proven once (at construction) and the refined value can be passed around unconstrained until it needs to be disassembled. Your APIs stay simpler: fewer where clauses, fewer repeated asserts.

A new type doesn't come for free though, as it's no longer freely interoperable with the original type. Make sure that the semantics are distinct enough to warrant the extra code. For example, writing a new type usually means implementing dunder methods corresponding to common operators (addition, subtraction, equality, and so on), which preserve the semantics of the new type.

Q3: Is the constraint understandable and provable by the caller?

If yes, use constraints.

Make the condition part of the function's contract so that callers must prove it.

This tends to be the right choice when:

Example:

def take_prefix[n: Int, len: Int](...) -> ... where 0 <= n <= len:
  ...

One practical heuristic: if a user can read the function signature and immediately understand what they did wrong when the constraint fails, where is likely the right tool.

If the constraint is not understandable or not provable by the user, use assert or abort.

If the "bad case" isn't something users would understand, adding a where constraint only causes more confusion, as users can't reasonably prove it themselves.

Summary

Calling functions

When calling a function, how do you show proof that you've satisfied the constraint?

This is where the constraint system actively guides you into handling exceptional cases.

Diagram: images metaprogramming constraint propagation

Q1: Do the constrained parameters come from a parent parameter list?

If the constrained parameter you're passing is from the function's parameter list, or is a parameter on the enclosing struct, you're basically "forwarding" a value from one parameter list to another. Go to Q2.

If not, you computed the value inside the function body, and you're in "construction" territory. Go to Q3.

Q2: Does the same limitation apply to your function?

If you're forwarding a constrained parameter into another constrained API, it's usually a hint to propagate the same requirement onto your own function.

For example, to propagate the constraint to your callers:

def create_list[T: Copyable, size: Int]() -> List[T] where size >= 0:
  return List[T](capacity = size)

# This function is also only meaningful when size >= 0.
# Make that part of the contract too.
def create_list_and_process[T: Copyable, size: Int](value: T) where size >= 0:
  comptime xs = create_list[T, size=size]()
  ...

But if you want your function to accept a wider input domain, your job is to handle both cases explicitly using a comptime if.

For example, narrow the domain by checking before the call:

def create_list_and_fill[size: Int]() -> Optional[List[size]]:
  comptime if size >= 0:
    return needs_nonneg[size]()
  else:
    return None

Q3: Do you know that the parameter already satisfies this condition?

If the parameter didn't come from a parent parameter list, it must have been computed in the body.

Decide whether the desired constraint holds by construction.

If it does, indicate this to the constraint system via a comptime assert. Note that this is you explicitly taking over the burden of proof.

Don't be afraid to write these asserts. The symbolic nature of the constraint system means that it is conservative—logical deductions that are obvious to you aren't always "obvious" to it. Adding comptime assert is not a code smell, but rather an inseparable part of working within the constraint system.

For example, given a computed parameter that is always valid:

# The constraint on hi & lo guarantees a valid size.
# Introduce this piece of knowledge explicitly.

  comptime size = hi - lo
  comptime assert size >= 0, "span is guaranteed non-negative"
  return create_list[size]()

But if the constraint does not necessarily hold, insert a comptime if and handle both cases explicitly.

For example, a computed parameter that may be invalid:

# This version does NOT have a constraint on its inputs.
# Branch on the computation to handle both cases.
def create_list_from_span[lo: Int, hi: Int]() -> Optional[List[hi - lo]]:
  comptime size = hi - lo
  comptime if size >= 0:
    return create_list[size]()
  else:
    return None

Summary