Skip to content
Assembling Me

Introducing sfo-mb: a beginner-friendly guide to a MoonBit type checker

WASM, Coding, Tech, Tutorials, Open Source • Joshua Tenner • Apr 4, 2026

Introducing sfo-mb: a beginner-friendly guide to a MoonBit type checker

If you are new to type systems, this is a practical walkthrough. The focus is on what the code is doing and why it helps.

When useful, I’ll point out the formal name so you can look it up later.

Before you start

This guide assumes only:

You do not need category theory or heavy notation.

Think in three practical buckets

sfo-mb is easiest to learn when you separate:

  1. writing expressions (called terms) like 1 + 2
  2. attaching a type to each expression, like Int
  3. tracking kinds, a simple tag that says “this is a normal type” versus “this is a type function”

sfo-mb keeps these pieces visible in code so you can see each step in one file.

A simple story map:

This project is not a full compiler. It is a focused core for checking and validating type-level behavior quickly.

What this project gives you

At a practical level, sfo-mb supports:

Quick glossary

Start with one tiny example

This is the smallest useful path into the API: set up a checker, build one term, then ask it for its type.

import "jtenner/sfo"

fn must_state(r : Result[TypeCheckerState, TypingError]) -> TypeCheckerState {
  match r {
    Ok(state) => state
    Err(_) => panic("initialization failed")
  }
}

fn must_type(r : Result[Type, TypingError]) -> Type {
  match r {
    Ok(ty) => ty
    Err(_) => panic("type check failed")
  }
}

fn main() {
  // Start with an empty state.
  let state0 = TypeCheckerState::fresh()

  // Add a couple of base types.
  let state1 = must_state(state0.add_type("Int", Kind::star()))
  let state2 = must_state(state1.add_type("Bool", Kind::star()))

  // λx:Int. x
  let id = Term::lam("x", Type::con("Int"), Term::var_term("x"))

  // Infer the type of that term.
  let id_ty = must_type(state2.infer_type(id))

  // Int -> Int
assert(id_ty == Type::arrow(Type::con("Int"), Type::con("Int")))
}

That sequence mirrors a plain typed language the same way:

fn must_state<T, E>(result: Result<T, E>) -> T {
  match result {
    Ok(state) => state,
    Err(_) => panic!("initialization failed"),
  }
}

fn main() {
  // A value and a concrete annotation force the compiler to infer behavior.
  let id = |x: i32| x; // inferred as fn(i32) -> i32
  let expected: fn(i32) -> i32 = id;
  assert_eq!(id(1), expected(1));
  let _state = must_state(Result::<(), &str>::Ok(()));
}

Keep this pattern in mind:

Read the same snippet as a tiny story of “code to meaning”:

  1. state0 is a fresh checkboard.
  2. state1 and state2 add known names (Int, Bool) so future code can name them.
  3. id is the term \x -> x with a specific type for x, so we now know its input and output are both Int.
  4. id_ty asks the checker to describe id, and we get back the summary Int -> Int.

You can treat this as a mini compiler log: input terms go in, expected obligations become explicit constraints, and a typed result comes out.

“Infer” vs “check” in normal language

You use two core operations:

A useful rule:

let expected = Type::arrow(Type::con("Int"), Type::con("Bool"))
let _ = state2.check_type(id, expected)

Here expected is Int -> Bool. If id does not match, check returns an error.

Rust shows the same “does this value satisfy this explicit target type?” flow too. For a two-argument function, this looks concrete:

fn add(x: i32, y: i32) -> i32 {
  x + y
}

fn main() {
  let expected: fn(i32, i32) -> i32 = add; // explicit target type check
  assert_eq!(expected(3, 4), 7);
}

In plain terms:

Context is the shared room the checker looks at while making these decisions.

Small feature tour

Polymorphism

Polymorphism is just reusable logic.

// Make a generic function by abstracting over a type variable A.
let poly = Term::tylam(
  "A",
  Kind::star(),
  Term::lam("x", Type::var_type("A"), Term::var_term("x")),
)

let poly_app = Term::tyapp(poly, Type::con("Int"))
let poly_ty = must_type(state2.infer_type(poly_app))
assert(poly_ty == Type::arrow(Type::con("Int"), Type::con("Int")))

If this looked helpful, here’s the same idea in words:

You can read each line as a meaning map:

At every step, the types are the explicit notes saying how one piece can replace another.

TypeScript uses the same pattern with generics:

function poly<A>(x: A): A {
  return x
}

const polyInt: (x: number) => number = poly

Traits as dictionaries

In this library, traits are explicit. You first write what a trait requires, then you provide a concrete dictionary that fills those requirements.

At the source level, you probably think of traits like this:

trait Eq[A] {
  fn eq(left: A, right: A): Bool
}

let intEq: Eq[Int] = { eq: (left, right) => left == right }
let boolEq: Eq[Bool] = { eq: (left, right) => left == right }

fn same[A](eq: Eq[A], left: A, right: A): Bool {
  eq.eq(left, right)
}

In TypeScript the same shape is typically:

type EqDict<T> = {
  eq: (left: T, right: T) => boolean
}

const intEq: EqDict<number> = {
  eq: (left, right) => left === right
}

const boolEq: EqDict<boolean> = {
  eq: (left, right) => left === right
}

function same<T>(dict: EqDict<T>, left: T, right: T): boolean {
  return dict.eq(left, right)
}

That same idea is made explicit in sfo-mb as trait declarations plus concrete dictionaries:

let state3 = must_state(
  state2.add_trait_def(
    "Eq",
    "A",
    Kind::star(),
    [("eq", Type::arrow(Type::var_type("A"), Type::arrow(Type::var_type("A"), Type::con("Bool"))))],
  ),
)

let int_dict = Term::dict(
  "Eq",
  Type::con("Int"),
  [
    (
      "eq",
      Term::lam(
        "left",
        Type::con("Int"),
        Term::lam("right", Type::con("Int"), Term::con("true", Type::con("Bool"))),
      ),
    ),
  ],
)

let bool_dict = Term::dict(
  "Eq",
  Type::con("Bool"),
  [
    (
      "eq",
      Term::lam(
        "left",
        Type::con("Bool"),
        Term::lam("right", Type::con("Bool"), Term::con("true", Type::con("Bool"))),
      ),
    ),
  ],
)

let state4 = must_state(state3.add_dict("eqInt", int_dict))
let state5 = must_state(state4.add_dict("eqBool", bool_dict))

You can model the same registration pattern as a value map:

const dicts = {
  eqInt: {
    eq: (left: number, right: number): boolean => left === right,
  },
  eqBool: {
    eq: (left: boolean, right: boolean): boolean => left === right,
  },
}

Why this is useful:

How this maps to concrete parts of the checker:

That pattern gives a clean separation:

Borrow-style terms

sfo-mb also includes borrow operations to model ownership restrictions. The checker can reject invalid aliasing patterns before code runs.

let invalid = Term::let_term(
  "x",
  Term::con("1", Type::con("Int")),
  Term::assign(
    Term::borrow_shared(Term::var_term("x")),
    Term::con("2", Type::con("Int")),
  ),
)

let borrow_result = state4.infer_type(invalid)
match borrow_result {
  Ok(_) => panic("expected borrow error")
  Err(_) => ()
}

Rust also checks borrow-style ownership rules before runtime:

fn main() {
  let mut x = 1;
  let shared = &x;

  // The next line would fail at compile time:
  // x = 2;

  // It is invalid for Rust because `x` is still immutably borrowed by `shared`.
  let _ = shared;
}

A practical sequence:

If this feels useful, jump to the project page for sfo-mb.