{"article":{"slug":"compile-time-dependent-types","title":"Compile-Time Dependent Types","subtitle":null,"summary":"Max Slater introduces Syl, a systems language that encodes compile-time computation in a modal type system inspired by two-level type theory and OxCaml: values carry staticity and erasure modes, enabling dependent types, proofs and totality checks, with examples like a typed interpreter and length-indexed vectors and planned extensions such as modules, reflection and user-defined layouts.","content_type":"blog_post","language":"en","canonical_url":"https://thenumb.at/Syl-Intro/","author":{"name":"Max Slater","url":"https://thenumb.at/","person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"thenumb.at","url":"https://thenumb.at/","listing_slug":null,"listing":null},"topics":[{"name":"Programming Languages","slug":"programming-languages","url":"https://listedarticles.com/topics/programming-languages"},{"name":"Compilers","slug":"compilers","url":"https://listedarticles.com/topics/compilers"},{"name":"Systems Programming","slug":"systems-programming","url":"https://listedarticles.com/topics/systems-programming"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":2608,"reading_minutes":11,"published_at":"2026-10-25T00:00:00.000Z","added_at":"2026-10-27T23:14:42.254Z","updated_at":"2026-10-27T23:14:42.254Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/compile-time-dependent-types","markdown_url":"https://listedarticles.com/articles/compile-time-dependent-types.md","example":false,"citation":"Max Slater, thenumb.at. \"Compile-Time Dependent Types.\" 25 Oct 2026. https://thenumb.at/Syl-Intro/ (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://thenumb.at/Syl-Intro/"},"body_markdown":"# Compile-Time Dependent Types\n\n*Max Slater · October 25, 2026*\n\nIt seems inevitable that compile-time dependent types will come to systems programming.  \nC++ has templates and `constexpr`. Rust has const generics. Zig has `comptime`. Even C has `_Generic`.\nHowever, these features tend to be highly restricted, lack a proper type system, or both.\n\nI’m working on [Syl](https://syl.dev/), a language which aims to encode compile-time computation in a modal type system.\nSyl is primarily inspired by András Kovács’ work on [two-level](https://andraskovacs.github.io/pdfs/2ltt.pdf) [type theory](https://andraskovacs.github.io/pdfs/2ltt_icfp24.pdf), as well as plans for [OxCaml](https://oxcaml.org/).\n\n*The Syl compiler may be found on [GitHub](https://github.com/TheNumbat/Syl).*\n\n- [Modes](#modes)\n- [Dependent Types](#dependent-types)\n- [Examples](#examples)\n- [Extensions](#extensions)\n- [Everything Else](#everything-else)\n\n---\n\n## Modes\n\nIn Syl, all values have a *mode* along with a type.\nModes describe properties of values independent of their types.\nFor example, [OxCaml uses modes](https://oxcaml.org/documentation/modes/intro/) to indicate whether a value is uniquely owned, may be written to, may be sent to another thread, etc.[1](#fn:1)\n\nSyl has two mode axes: *staticity* and *erasure*.\nOn the staticity axis, a value may be either `static` or `dynamic`.\nOn erasure, either `erased` or `unerased`.\n\n- A `static` value is knowable at compile time; a `dynamic` value may not be.\n- An `unerased` value is knowable at runtime; an `erased` value may not be.\n\nFor example, literals are `static unerased`, since they are clearly knowable at both compile time and runtime.\nYou can check by writing a mode annotation:\n\n```\nlet x = 0 @ static unerased;;\n```\n\nEach mode axis has a sub-moding relation.\nIf you have a `static` value, it’s safe to forget that it is knowable at compile time and hence treat it as `dynamic`.\nSimilarly, an `unerased` value may be treated as `erased`.\nMode annotations may “weaken” the mode, but not “strengthen” it:\n\n```\nlet x = 0 @ dynamic;;\n\nlet y = x @ static;;\n        ^^^^^^^^^^\nError: Expected a static expression, but got dynamic.\n```\n\nAs you might expect, branching on a `dynamic` value causes the result to be `dynamic`.\n\n```\nlet x = 0 @ dynamic;;\n\nlet y = (if x == 0 then 1 else 2) @ static;;\n        ^^^^^^^^^^^^^^^^^^^^^^^^\nError: Expected a static expression, but got dynamic.\n```\n\nAs long as they don’t capture or branch on `dynamic` values, functions preserve staticity.[2](#fn:2)\n\n```\nlet f = fn (x : int) -> x;;\n\nlet x = (f 0) @ static;;\n\nlet y = (f (1 @ dynamic)) @ static;;\n        ^^^^^^^^^^^^^^^^^\nError: Expected a static expression, but got dynamic.\n```\n\nFunctions that return `erased` values are themselves `erased`, so cannot be applied at runtime.\n\n```\nlet f = fn (x : unit) -> 1 @ erased;;\n\nlet g = f @ unerased;;\n        ^\nError: Expected an unerased expression, but got erased.\n```\n\nNow, what can we do with this information?\nWhen a value is knowable at compile time, surrounding code can be *monomorphized*.\nFunctions of `static` parameters take advantage of this fact:\n\n```\nlet f = fn (static x : bool) -> print_bool x;;\n```\n\nWhenever we call `f x`, the compiler requires that both `f` and `x` have mode `static`.\nIf they do, it selects a particular implementation of `f` specialized to the value of `x`.\nConcretely, `f true` would call a function that hard-codes `print_bool true`, and likewise for `f false`.\n\nIn this example, we’re printing the value of `x` at runtime, so it needs to be `unerased`.\nThat’s often unnecessary: if `x` only determines how to compile the body of `f`, it can be `erased`.\n\n```\nlet f = fn (erased x : bool) -> if erased x then print_int 0 else print_int 1;;\n```\n\nHere, `erased` also implies `static`, so `f` will be specialized on `x`.\nThe `if erased` expression accepts a `static erased` condition and is guaranteed to resolve at compile time.\nTherefore, the body of `f true` is simply `print_int 0`, and likewise for `f false`.\n\n---\n\n## Dependent Types\n\nOn their own, the `static` and `erased` modes let us explicitly request specialization-based optimizations,\nbut they don’t give us dependent types. Functions of `static` arguments do.\n\n```\nlet f = fn (erased x : bool) -> if erased x then 0 else true;;\n```\n\nHere, `if erased` allows us to return an integer if `x` is true and a boolean otherwise.\nHence, `f` has a dependent function type, which encodes how the result type depends on the *value* of the input.\n\n```\nf : erased bool as x -> if erased x then int else bool\n```\n\nNote `ty as name` gives the argument a name for use in the result.\n\nApplications can then determine the correct result type by evaluating `f`’s type.\n\n```\nlet x = (f true) : int;;\n\nlet y = (f false) : bool;;\n```\n\nThere is no distinct type grammar: type formers like `->` and type annotations are normal expressions.\n`int` and `bool` are just `erased` values of type `type`, and abstracting over them gives us polymorphism.\n\n```\nlet id = fn (erased t : type) -> fn (x : t) -> x;;\n\nlet x = id int 0;;\n\nlet y = id bool true;;\n```\n\nAs we saw above, functions of `static` arguments are monomorphized.\nAt runtime, `id int` refers to an identity function that directly operates on integers, and likewise for `id bool`.\n\nThat is, Syl is **dependently typed at compile time and simply typed at runtime.**[3](#fn:3)\n\n- Constructs that determine the shape of the runtime program require `static` information.\n- Types are always `erased`, so the runtime program cannot inspect types.\n\nSyl implements dependent typechecking similarly to existing languages, so we won’t discuss it in detail.\nHowever, the relationship between types and propositions in Syl differs from that of languages like Lean, Rocq, and Agda.\n\n### Proofs\n\nIn particular, Syl supports general recursion in compile time computations and gives `type` type `type`.\nAllowing either of these features implies that constructing an expression of type `t` does **not** prove the proposition corresponding to `t`.\n\nFor example, we can implement a function that ostensibly returns a value of any type:\n\n```\nlet magic =\n  fn (erased t : type) ->\n    fun loop (_ : unit) : t = loop () in\n    loop ()\n;;\n```\n\nNote `magic` could also be implemented using `type : type` via [Girard’s paradox](https://ncatlab.org/nlab/show/Burali-Forti%27s+paradox).\n\nDespite having type `erased type as t -> t`, this function never actually produces a value—it’s an infinite loop.\nThe type describes what `magic t` would produce if it completed, but does not guarantee that it does.\n\nAlthough this type system would not be suitable for a theorem proving language, it does *not* rule out type safety or even useful proofs.\nIt has two practical consequences:\n\n- Functions used in type-level computations may diverge, so typechecking is not decidable.\n- Functions don’t discharge universally quantified claims, so the compiler must evaluate all applications that produce proofs before relying on their results.[4](#fn:4)[5](#fn:5)\n\nIn return, we don’t need a totality checker (all `static` expressions may be used in types) or universe machinery (type-level programming is intuitive).\n\n### Totality\n\nBecause Syl is a *programming language*, the point of compilation is to produce a runtime artifact satisfying the invariants enforced by the type system.\nTherefore, we only care about invocations of dependent functions that actually occur during compilation—it’s fine for others to diverge.\n\nStill, typechecking getting stuck during development is a poor user experience, so Syl also implements fuel limits.\nGiven competent compile-time debugging tools, I think this is reasonable.\n\n```\nfun f (erased _ : unit) : erased type = f ();;\n                                        ^\nError: Recursion limit reached.\n\nlet x = 1 : f ();;\n```\n\nEschewing totality also means Syl can support explicitly partial functions at little additional cost.\nFor example, here’s a division function that statically checks the divisor is non-zero:\n\n```\nlet safe_div =\n  fn (static d : int) ->\n    if erased d != 0\n    then fn (x : int) -> x / d\n    else unreachable\n;;\n```\n\nThe `unreachable` keyword asserts that a branch does not appear in the runtime program.\nDuring dependent typechecking, the `else` branch is treated as a bottom value, so imposes no restrictions on the `then` branch.\nHowever, if `else` is reached while instantiating `safe_div`, we get an error:\n\n```\n    ...\n    else unreachable\n         ^^^^^^^^^^^\nError: Unreachable reached.\n;;\n\nlet x = safe_div 0 1;;\n```\n\nAllowing partial functions reopens the door to inscrutable C++-style template instantiation errors.\nLuckily, explicit partiality means we can require users to provide error messages in unsupported cases, and combined with the lack of duck-typing at compile time, I think Syl can avoid most of the downsides.\n\n---\n\n## Examples\n\n### Typed Interpreter\n\nThe following program does not require the full power of dependent types—it’s often used as a motivating example for GADTs in OCaml or Haskell—but I think the comparison is illustrative.\n\nWe’ll start by defining a variant to use as an index.\n\n```\nlet sort = variant { int, bool };;\n```\n\n*Notes: Both `sort` and its constructors `sort.int`/`sort.bool` are normal values. Variants are unboxed.*\n\nThen a function to convert a `sort` to the corresponding type.\n\n```\nfun value (erased s : sort) : erased type =\n  match erased s {\n    .int -> int,\n    .bool -> bool,\n  }\n;;\n```\n\n*Note: like `if erased`, `match erased` allows an `erased` scrutinee and is resolved at compile time.*\n\nNext, an indexed expression type.\nThe type is literally a recursive function with a `sort` argument:\n\n```\nfun expr (erased s : sort) : erased type =\n  match erased s {\n    .int ->\n      variant {\n        lit : int,\n        add : &(expr sort.int ^ expr sort.int),\n        if_ : &(expr sort.bool ^ expr sort.int ^ expr sort.int),\n      },\n    .bool ->\n      variant {\n        lit : bool,\n        eq : &(expr sort.int ^ expr sort.int),\n      },\n  }\n;;\n```\n\n*Notes: `&` represents an indirection. `^` is the tuple type former.*\n\nFinally, the interpreter.\nIn the `.int` branch, the compiler knows that only the `int`-returning constructors appear, and likewise for `.bool`.\n\n```\nfun eval (erased s : sort) : expr s -> value s =\n  fn (e : expr s) ->\n    match erased s {\n      .int ->\n        match e {\n          .lit n -> n,\n          .add &(left, right) -> eval sort.int left + eval sort.int right,\n          .if_ &(condition, yes, no) ->\n            if eval sort.bool condition then eval sort.int yes else eval sort.int no,\n        },\n      .bool ->\n        match e {\n          .lit b -> b,\n          .eq &(left, right) -> eval sort.int left == eval sort.int right,\n        },\n    }\n;;\n```\n\nFunctions of `static` arguments are monomorphized and `erased` branches are eliminated, so the interpreter compiles to the same structure you’d expect from template specialization in C++.[6](#fn:6)\n\n### Length-Indexed Vector\n\n*Warning: the syntax here needs a lot of work.*\n\nProper dependent types enable us to encode the length of lists in the type system.\nLet’s write an `append` function whose type shows that the output length is the sum of the input lengths.\n\nWhile we could index our list by an `int`, Syl lacks tools for reasoning about signed, fixed-width integers.\nInstead, let’s define our own Peano `nat` type.\n\n```\nlet nat =\n  fun nat (erased _ : unit) : erased type =\n    variant { zero, succ : &(nat ()) }\n  in\n  nat ()\n;;\n```\n\n*Note: there’s no `let rec`, so we define `nat` via a recursive function.*\n\nThen an addition function for `nat`s.\n\n```\nfun add (static nm : nat ^ nat) : nat =\n  match static nm {\n    (.zero, m) -> m,\n    (.succ &n, m) -> nat.succ box (add (n, m)),\n  }\n;;\n```\n\n*Notes: `match static` is like `match erased` but does not erase the contents of the scrutinee. We should use `erased` everywhere, but variant constructors are not yet mode-polymorphic. If all `nat`s were erased, the `box` indirection also wouldn’t be necessary.*\n\nNow the unboxed, length-indexed list.\nNote the lack of indirection.\n\n```\nfun vec (erased t : type) : erased (erased nat -> erased type) =\n  fn (erased n : nat) ->\n    match erased n {\n      .zero -> variant { nil },\n      .succ &n -> variant { cons : t ^ vec t n },\n    }\n;;\n```\n\nFinally, the `append` function.\nCrucially, the compiler is able to infer that the `.cons` case has the correct return type by unfolding `add` and `vec`.\n\n```\nfun append\n  (erased t : type) : (static nat as n\n                         -> static nat as m\n                         -> vec t n\n                         -> vec t m\n                         -> vec t (add (n, m))) =\n  fn (static n : nat) ->\n    fn (static m : nat) ->\n      fn (v1 : vec t n) ->\n        fn (v2 : vec t m) ->\n          match static n {\n            .zero -> v2,\n            .succ &n_minus_1 ->\n              match v1 {\n                .cons (x, xs) -> (vec t (add (n, m))).cons (x, append t n_minus_1 m xs v2),\n              },\n          }\n;;\n```\n\n*Notes: this is rather verbose since we don’t have n-ary function syntax. We also have to fully qualify the `vec` type `.cons` comes from because it’s not inferred yet.*\n\nAs above, all lengths are known at compile time, so the runtime program has semantics akin to a `constexpr` length computation in C++ (though the `unerased` lengths may be materialized).\n\n---\n\n## Extensions\n\nMany useful language features can be defined in user code using compile-time dependent types.\nHere are few promising (partially implemented) examples.\n\n### Higher-Kinded Types\n\nHigher-kinded types are just `erased` functions on types.\nYou can abstract over them like anything else.\n\n```\nsingleton :\n    erased (erased type -> erased type) as container ->\n    erased type as element ->\n    element ->\n    container element\n```\n\nThis example already works, but as written, all you can do with `container` is apply it.\nWe also need the ability to specify interfaces that connect types and values, leading to…\n\n### Modules and Functors\n\nOnce Syl has [OxCaml-style modalities](https://oxcaml.org/documentation/modes/reference/#modalities)[7](#fn:7), you’ll be able to nest `erased`/`dynamic` values inside `unerased`/`static` values.\nThen dependent records can take the place of ML-style modules:\n\n```\nlet container = record {\n  ty : erased type -> erased type @@ erased,\n  create : erased type as element -> element -> ty element,\n};;\n```\n\nNote `@@ erased` implies that all uses of `container.ty` see an `erased` value regardless of the erasure of `container`.\nNow we can give `singleton` an implementable type:\n\n```\nsingleton :\n    static container as container ->\n    erased type as element ->\n    element ->\n    container.ty element\n```\n\nSimilarly, ML-style functors can be implemented as dependent functions:\n\n```\nfun pair (static c : container) : container = {\n  ty = (fn (erased element : type) ->\n         c.ty element ^ c.ty element),\n  create = (fn (erased element : type) ->\n             fn (e : element) ->\n               c.create element e, c.create element e),\n};;\n```\n\nFinally, with inferred arguments, records could provide most of the affordances of typeclasses or traits.\n\n### Imports\n\nWe can even use static computation to describe how multi-file programs are built.\nThe compiler just has to provide the following functions:\n\n```\nimport_sig : erased string -> erased type\nimport : erased string as path -> import_sig path\n```\n\nIf we treat file-level definitions as record fields, `import_sig` can infer a record type for a file and `import` can produce a value of that type.\nIn fact, these functions could just as easily take code strings, letting us generate code at compile time!\n\n### Reflection\n\nSyl already has rudimentary reflection via the built-in `repr` function, which returns metadata for a type.\n\n```\nfun print_ty (erased t : type) : unit =\n  match erased repr t {\n    .unit -> print \"unit\",\n    .bool -> print \"bool\",\n    .int -> print \"int\",\n    _ -> unreachable,\n  }\n;;\n```\n\nThis facility could be straightforwardly extended to allow inspecting the structure of types, which could then be used to implement typed Rust or OCaml-style derivers.\n\n### User-Defined Layouts\n\nWhile fairly speculative, I think we should be able to define and manipulate types’ runtime layouts at compile time.\nWith such a feature, users could safely describe interoperable niche optimizations, possibly in the style of [*Bit-Stealing Made Legal*](https://inria.hal.science/hal-04165615v1/document).\nThis would also require some notion of “unaddressable” fields that do not allow creating pointers to them.\n\n---\n\n## Everything Else\n\nSyl’s design is far from complete.\nIn particular, it needs stories for mode polymorphism, lifetimes, linearity, implicit parameters, and proofs.\nThe compiler needs incremental compilation, useful error traces, and an LLVM backend.\nI hope to share more thoughts on these areas in the future.\n\n---\n\n# Footnotes\n\n---\n\n1. In the literature, modal type systems are presented in terms of modalities.\n   We will describe modes as they appear in OxCaml, but you can translate by thinking of all values as `static unerased`\n   until captured under a `dynamic` or `erased` modality. [↩︎](#fnref:1)\n2. Currently, staticity has modal axiom K, which allows staticity-preserving functions to operate\n   on dynamic arguments. I plan to replace the implicit application rule with mode polymorphism, which can also handle erasure. [↩︎](#fnref:2)\n3. This is the core idea of [*Staged Compilation with Two-Level Type Theory*](https://andraskovacs.github.io/pdfs/2ltt.pdf), though it’s not presented as a modal type system. [↩︎](#fnref:3)\n4. This idea is formally explored in [*Irrelevance, Heterogeneous Equality, and Call-by-value Dependent Type Systems*](https://www.cis.upenn.edu/~sweirich/papers/msfp12prog.pdf). Thanks to Chris for proofreading this post! [↩︎](#fnref:4)\n5. Syl does not yet have equality types, but once it does, erasure will provide a natural mechanism for forcing proof-producing computations when their results are needed. For example, `assert erased b` could establish `b = true` in the proof context by checking it reduces to `true`. [↩︎](#fnref:5)\n6. This isn’t a fair comparison, but it’s better! [C++](https://godbolt.org/z/zP3Y9PjMG) / [Syl](https://pastebin.com/BNrfcP8B). [↩︎](#fnref:6)\n7. There exist ideas on how to make such modalities inferrable, but I don’t understand them yet. [↩︎](#fnref:7)\n\n---\n\n*Originally published at [thenumb.at](https://thenumb.at/Syl-Intro/).*\n","body_html":"<h1 id=\"compile-time-dependent-types\">Compile-Time Dependent Types</h1>\n<p><em>Max Slater · October 25, 2026</em></p>\n<p>It seems inevitable that compile-time dependent types will come to systems programming.<br />\nC++ has templates and <code>constexpr</code>. Rust has const generics. Zig has <code>comptime</code>. Even C has <code>_Generic</code>.\nHowever, these features tend to be highly restricted, lack a proper type system, or both.</p>\n<p>I’m working on <a href=\"https://syl.dev/\" rel=\"nofollow ugc noopener\">Syl</a>, a language which aims to encode compile-time computation in a modal type system.\nSyl is primarily inspired by András Kovács’ work on <a href=\"https://andraskovacs.github.io/pdfs/2ltt.pdf\" rel=\"nofollow ugc noopener\">two-level</a> <a href=\"https://andraskovacs.github.io/pdfs/2ltt_icfp24.pdf\" rel=\"nofollow ugc noopener\">type theory</a>, as well as plans for <a href=\"https://oxcaml.org/\" rel=\"nofollow ugc noopener\">OxCaml</a>.</p>\n<p><em>The Syl compiler may be found on <a href=\"https://github.com/TheNumbat/Syl\" rel=\"nofollow ugc noopener\">GitHub</a>.</em></p>\n<ul><li><a href=\"#modes\">Modes</a></li><li><a href=\"#dependent-types\">Dependent Types</a></li><li><a href=\"#examples\">Examples</a></li><li><a href=\"#extensions\">Extensions</a></li><li><a href=\"#everything-else\">Everything Else</a></li></ul>\n<hr />\n<h2 id=\"modes\">Modes</h2>\n<p>In Syl, all values have a <em>mode</em> along with a type.\nModes describe properties of values independent of their types.\nFor example, <a href=\"https://oxcaml.org/documentation/modes/intro/\" rel=\"nofollow ugc noopener\">OxCaml uses modes</a> to indicate whether a value is uniquely owned, may be written to, may be sent to another thread, etc.<a href=\"#fn:1\">1</a></p>\n<p>Syl has two mode axes: <em>staticity</em> and <em>erasure</em>.\nOn the staticity axis, a value may be either <code>static</code> or <code>dynamic</code>.\nOn erasure, either <code>erased</code> or <code>unerased</code>.</p>\n<ul><li>A <code>static</code> value is knowable at compile time; a <code>dynamic</code> value may not be.</li><li>An <code>unerased</code> value is knowable at runtime; an <code>erased</code> value may not be.</li></ul>\n<p>For example, literals are <code>static unerased</code>, since they are clearly knowable at both compile time and runtime.\nYou can check by writing a mode annotation:</p>\n<pre><code>let x = 0 @ static unerased;;</code></pre>\n<p>Each mode axis has a sub-moding relation.\nIf you have a <code>static</code> value, it’s safe to forget that it is knowable at compile time and hence treat it as <code>dynamic</code>.\nSimilarly, an <code>unerased</code> value may be treated as <code>erased</code>.\nMode annotations may “weaken” the mode, but not “strengthen” it:</p>\n<pre><code>let x = 0 @ dynamic;;\n\nlet y = x @ static;;\n        ^^^^^^^^^^\nError: Expected a static expression, but got dynamic.</code></pre>\n<p>As you might expect, branching on a <code>dynamic</code> value causes the result to be <code>dynamic</code>.</p>\n<pre><code>let x = 0 @ dynamic;;\n\nlet y = (if x == 0 then 1 else 2) @ static;;\n        ^^^^^^^^^^^^^^^^^^^^^^^^\nError: Expected a static expression, but got dynamic.</code></pre>\n<p>As long as they don’t capture or branch on <code>dynamic</code> values, functions preserve staticity.<a href=\"#fn:2\">2</a></p>\n<pre><code>let f = fn (x : int) -&gt; x;;\n\nlet x = (f 0) @ static;;\n\nlet y = (f (1 @ dynamic)) @ static;;\n        ^^^^^^^^^^^^^^^^^\nError: Expected a static expression, but got dynamic.</code></pre>\n<p>Functions that return <code>erased</code> values are themselves <code>erased</code>, so cannot be applied at runtime.</p>\n<pre><code>let f = fn (x : unit) -&gt; 1 @ erased;;\n\nlet g = f @ unerased;;\n        ^\nError: Expected an unerased expression, but got erased.</code></pre>\n<p>Now, what can we do with this information?\nWhen a value is knowable at compile time, surrounding code can be <em>monomorphized</em>.\nFunctions of <code>static</code> parameters take advantage of this fact:</p>\n<pre><code>let f = fn (static x : bool) -&gt; print_bool x;;</code></pre>\n<p>Whenever we call <code>f x</code>, the compiler requires that both <code>f</code> and <code>x</code> have mode <code>static</code>.\nIf they do, it selects a particular implementation of <code>f</code> specialized to the value of <code>x</code>.\nConcretely, <code>f true</code> would call a function that hard-codes <code>print_bool true</code>, and likewise for <code>f false</code>.</p>\n<p>In this example, we’re printing the value of <code>x</code> at runtime, so it needs to be <code>unerased</code>.\nThat’s often unnecessary: if <code>x</code> only determines how to compile the body of <code>f</code>, it can be <code>erased</code>.</p>\n<pre><code>let f = fn (erased x : bool) -&gt; if erased x then print_int 0 else print_int 1;;</code></pre>\n<p>Here, <code>erased</code> also implies <code>static</code>, so <code>f</code> will be specialized on <code>x</code>.\nThe <code>if erased</code> expression accepts a <code>static erased</code> condition and is guaranteed to resolve at compile time.\nTherefore, the body of <code>f true</code> is simply <code>print_int 0</code>, and likewise for <code>f false</code>.</p>\n<hr />\n<h2 id=\"dependent-types\">Dependent Types</h2>\n<p>On their own, the <code>static</code> and <code>erased</code> modes let us explicitly request specialization-based optimizations,\nbut they don’t give us dependent types. Functions of <code>static</code> arguments do.</p>\n<pre><code>let f = fn (erased x : bool) -&gt; if erased x then 0 else true;;</code></pre>\n<p>Here, <code>if erased</code> allows us to return an integer if <code>x</code> is true and a boolean otherwise.\nHence, <code>f</code> has a dependent function type, which encodes how the result type depends on the <em>value</em> of the input.</p>\n<pre><code>f : erased bool as x -&gt; if erased x then int else bool</code></pre>\n<p>Note <code>ty as name</code> gives the argument a name for use in the result.</p>\n<p>Applications can then determine the correct result type by evaluating <code>f</code>’s type.</p>\n<pre><code>let x = (f true) : int;;\n\nlet y = (f false) : bool;;</code></pre>\n<p>There is no distinct type grammar: type formers like <code>-&gt;</code> and type annotations are normal expressions.\n<code>int</code> and <code>bool</code> are just <code>erased</code> values of type <code>type</code>, and abstracting over them gives us polymorphism.</p>\n<pre><code>let id = fn (erased t : type) -&gt; fn (x : t) -&gt; x;;\n\nlet x = id int 0;;\n\nlet y = id bool true;;</code></pre>\n<p>As we saw above, functions of <code>static</code> arguments are monomorphized.\nAt runtime, <code>id int</code> refers to an identity function that directly operates on integers, and likewise for <code>id bool</code>.</p>\n<p>That is, Syl is <strong>dependently typed at compile time and simply typed at runtime.</strong><a href=\"#fn:3\">3</a></p>\n<ul><li>Constructs that determine the shape of the runtime program require <code>static</code> information.</li><li>Types are always <code>erased</code>, so the runtime program cannot inspect types.</li></ul>\n<p>Syl implements dependent typechecking similarly to existing languages, so we won’t discuss it in detail.\nHowever, the relationship between types and propositions in Syl differs from that of languages like Lean, Rocq, and Agda.</p>\n<h3 id=\"proofs\">Proofs</h3>\n<p>In particular, Syl supports general recursion in compile time computations and gives <code>type</code> type <code>type</code>.\nAllowing either of these features implies that constructing an expression of type <code>t</code> does <strong>not</strong> prove the proposition corresponding to <code>t</code>.</p>\n<p>For example, we can implement a function that ostensibly returns a value of any type:</p>\n<pre><code>let magic =\n  fn (erased t : type) -&gt;\n    fun loop (_ : unit) : t = loop () in\n    loop ()\n;;</code></pre>\n<p>Note <code>magic</code> could also be implemented using <code>type : type</code> via <a href=\"https://ncatlab.org/nlab/show/Burali-Forti%27s+paradox\" rel=\"nofollow ugc noopener\">Girard’s paradox</a>.</p>\n<p>Despite having type <code>erased type as t -&gt; t</code>, this function never actually produces a value—it’s an infinite loop.\nThe type describes what <code>magic t</code> would produce if it completed, but does not guarantee that it does.</p>\n<p>Although this type system would not be suitable for a theorem proving language, it does <em>not</em> rule out type safety or even useful proofs.\nIt has two practical consequences:</p>\n<ul><li>Functions used in type-level computations may diverge, so typechecking is not decidable.</li><li>Functions don’t discharge universally quantified claims, so the compiler must evaluate all applications that produce proofs before relying on their results.<a href=\"#fn:4\">4</a><a href=\"#fn:5\">5</a></li></ul>\n<p>In return, we don’t need a totality checker (all <code>static</code> expressions may be used in types) or universe machinery (type-level programming is intuitive).</p>\n<h3 id=\"totality\">Totality</h3>\n<p>Because Syl is a <em>programming language</em>, the point of compilation is to produce a runtime artifact satisfying the invariants enforced by the type system.\nTherefore, we only care about invocations of dependent functions that actually occur during compilation—it’s fine for others to diverge.</p>\n<p>Still, typechecking getting stuck during development is a poor user experience, so Syl also implements fuel limits.\nGiven competent compile-time debugging tools, I think this is reasonable.</p>\n<pre><code>fun f (erased _ : unit) : erased type = f ();;\n                                        ^\nError: Recursion limit reached.\n\nlet x = 1 : f ();;</code></pre>\n<p>Eschewing totality also means Syl can support explicitly partial functions at little additional cost.\nFor example, here’s a division function that statically checks the divisor is non-zero:</p>\n<pre><code>let safe_div =\n  fn (static d : int) -&gt;\n    if erased d != 0\n    then fn (x : int) -&gt; x / d\n    else unreachable\n;;</code></pre>\n<p>The <code>unreachable</code> keyword asserts that a branch does not appear in the runtime program.\nDuring dependent typechecking, the <code>else</code> branch is treated as a bottom value, so imposes no restrictions on the <code>then</code> branch.\nHowever, if <code>else</code> is reached while instantiating <code>safe_div</code>, we get an error:</p>\n<pre><code>    ...\n    else unreachable\n         ^^^^^^^^^^^\nError: Unreachable reached.\n;;\n\nlet x = safe_div 0 1;;</code></pre>\n<p>Allowing partial functions reopens the door to inscrutable C++-style template instantiation errors.\nLuckily, explicit partiality means we can require users to provide error messages in unsupported cases, and combined with the lack of duck-typing at compile time, I think Syl can avoid most of the downsides.</p>\n<hr />\n<h2 id=\"examples\">Examples</h2>\n<h3 id=\"typed-interpreter\">Typed Interpreter</h3>\n<p>The following program does not require the full power of dependent types—it’s often used as a motivating example for GADTs in OCaml or Haskell—but I think the comparison is illustrative.</p>\n<p>We’ll start by defining a variant to use as an index.</p>\n<pre><code>let sort = variant { int, bool };;</code></pre>\n<p><em>Notes: Both <code>sort</code> and its constructors <code>sort.int</code>/<code>sort.bool</code> are normal values. Variants are unboxed.</em></p>\n<p>Then a function to convert a <code>sort</code> to the corresponding type.</p>\n<pre><code>fun value (erased s : sort) : erased type =\n  match erased s {\n    .int -&gt; int,\n    .bool -&gt; bool,\n  }\n;;</code></pre>\n<p><em>Note: like <code>if erased</code>, <code>match erased</code> allows an <code>erased</code> scrutinee and is resolved at compile time.</em></p>\n<p>Next, an indexed expression type.\nThe type is literally a recursive function with a <code>sort</code> argument:</p>\n<pre><code>fun expr (erased s : sort) : erased type =\n  match erased s {\n    .int -&gt;\n      variant {\n        lit : int,\n        add : &amp;(expr sort.int ^ expr sort.int),\n        if_ : &amp;(expr sort.bool ^ expr sort.int ^ expr sort.int),\n      },\n    .bool -&gt;\n      variant {\n        lit : bool,\n        eq : &amp;(expr sort.int ^ expr sort.int),\n      },\n  }\n;;</code></pre>\n<p><em>Notes: <code>&amp;</code> represents an indirection. <code>^</code> is the tuple type former.</em></p>\n<p>Finally, the interpreter.\nIn the <code>.int</code> branch, the compiler knows that only the <code>int</code>-returning constructors appear, and likewise for <code>.bool</code>.</p>\n<pre><code>fun eval (erased s : sort) : expr s -&gt; value s =\n  fn (e : expr s) -&gt;\n    match erased s {\n      .int -&gt;\n        match e {\n          .lit n -&gt; n,\n          .add &amp;(left, right) -&gt; eval sort.int left + eval sort.int right,\n          .if_ &amp;(condition, yes, no) -&gt;\n            if eval sort.bool condition then eval sort.int yes else eval sort.int no,\n        },\n      .bool -&gt;\n        match e {\n          .lit b -&gt; b,\n          .eq &amp;(left, right) -&gt; eval sort.int left == eval sort.int right,\n        },\n    }\n;;</code></pre>\n<p>Functions of <code>static</code> arguments are monomorphized and <code>erased</code> branches are eliminated, so the interpreter compiles to the same structure you’d expect from template specialization in C++.<a href=\"#fn:6\">6</a></p>\n<h3 id=\"length-indexed-vector\">Length-Indexed Vector</h3>\n<p><em>Warning: the syntax here needs a lot of work.</em></p>\n<p>Proper dependent types enable us to encode the length of lists in the type system.\nLet’s write an <code>append</code> function whose type shows that the output length is the sum of the input lengths.</p>\n<p>While we could index our list by an <code>int</code>, Syl lacks tools for reasoning about signed, fixed-width integers.\nInstead, let’s define our own Peano <code>nat</code> type.</p>\n<pre><code>let nat =\n  fun nat (erased _ : unit) : erased type =\n    variant { zero, succ : &amp;(nat ()) }\n  in\n  nat ()\n;;</code></pre>\n<p><em>Note: there’s no <code>let rec</code>, so we define <code>nat</code> via a recursive function.</em></p>\n<p>Then an addition function for <code>nat</code>s.</p>\n<pre><code>fun add (static nm : nat ^ nat) : nat =\n  match static nm {\n    (.zero, m) -&gt; m,\n    (.succ &amp;n, m) -&gt; nat.succ box (add (n, m)),\n  }\n;;</code></pre>\n<p><em>Notes: <code>match static</code> is like <code>match erased</code> but does not erase the contents of the scrutinee. We should use <code>erased</code> everywhere, but variant constructors are not yet mode-polymorphic. If all <code>nat</code>s were erased, the <code>box</code> indirection also wouldn’t be necessary.</em></p>\n<p>Now the unboxed, length-indexed list.\nNote the lack of indirection.</p>\n<pre><code>fun vec (erased t : type) : erased (erased nat -&gt; erased type) =\n  fn (erased n : nat) -&gt;\n    match erased n {\n      .zero -&gt; variant { nil },\n      .succ &amp;n -&gt; variant { cons : t ^ vec t n },\n    }\n;;</code></pre>\n<p>Finally, the <code>append</code> function.\nCrucially, the compiler is able to infer that the <code>.cons</code> case has the correct return type by unfolding <code>add</code> and <code>vec</code>.</p>\n<pre><code>fun append\n  (erased t : type) : (static nat as n\n                         -&gt; static nat as m\n                         -&gt; vec t n\n                         -&gt; vec t m\n                         -&gt; vec t (add (n, m))) =\n  fn (static n : nat) -&gt;\n    fn (static m : nat) -&gt;\n      fn (v1 : vec t n) -&gt;\n        fn (v2 : vec t m) -&gt;\n          match static n {\n            .zero -&gt; v2,\n            .succ &amp;n_minus_1 -&gt;\n              match v1 {\n                .cons (x, xs) -&gt; (vec t (add (n, m))).cons (x, append t n_minus_1 m xs v2),\n              },\n          }\n;;</code></pre>\n<p><em>Notes: this is rather verbose since we don’t have n-ary function syntax. We also have to fully qualify the <code>vec</code> type <code>.cons</code> comes from because it’s not inferred yet.</em></p>\n<p>As above, all lengths are known at compile time, so the runtime program has semantics akin to a <code>constexpr</code> length computation in C++ (though the <code>unerased</code> lengths may be materialized).</p>\n<hr />\n<h2 id=\"extensions\">Extensions</h2>\n<p>Many useful language features can be defined in user code using compile-time dependent types.\nHere are few promising (partially implemented) examples.</p>\n<h3 id=\"higher-kinded-types\">Higher-Kinded Types</h3>\n<p>Higher-kinded types are just <code>erased</code> functions on types.\nYou can abstract over them like anything else.</p>\n<pre><code>singleton :\n    erased (erased type -&gt; erased type) as container -&gt;\n    erased type as element -&gt;\n    element -&gt;\n    container element</code></pre>\n<p>This example already works, but as written, all you can do with <code>container</code> is apply it.\nWe also need the ability to specify interfaces that connect types and values, leading to…</p>\n<h3 id=\"modules-and-functors\">Modules and Functors</h3>\n<p>Once Syl has <a href=\"https://oxcaml.org/documentation/modes/reference/#modalities\" rel=\"nofollow ugc noopener\">OxCaml-style modalities</a><a href=\"#fn:7\">7</a>, you’ll be able to nest <code>erased</code>/<code>dynamic</code> values inside <code>unerased</code>/<code>static</code> values.\nThen dependent records can take the place of ML-style modules:</p>\n<pre><code>let container = record {\n  ty : erased type -&gt; erased type @@ erased,\n  create : erased type as element -&gt; element -&gt; ty element,\n};;</code></pre>\n<p>Note <code>@@ erased</code> implies that all uses of <code>container.ty</code> see an <code>erased</code> value regardless of the erasure of <code>container</code>.\nNow we can give <code>singleton</code> an implementable type:</p>\n<pre><code>singleton :\n    static container as container -&gt;\n    erased type as element -&gt;\n    element -&gt;\n    container.ty element</code></pre>\n<p>Similarly, ML-style functors can be implemented as dependent functions:</p>\n<pre><code>fun pair (static c : container) : container = {\n  ty = (fn (erased element : type) -&gt;\n         c.ty element ^ c.ty element),\n  create = (fn (erased element : type) -&gt;\n             fn (e : element) -&gt;\n               c.create element e, c.create element e),\n};;</code></pre>\n<p>Finally, with inferred arguments, records could provide most of the affordances of typeclasses or traits.</p>\n<h3 id=\"imports\">Imports</h3>\n<p>We can even use static computation to describe how multi-file programs are built.\nThe compiler just has to provide the following functions:</p>\n<pre><code>import_sig : erased string -&gt; erased type\nimport : erased string as path -&gt; import_sig path</code></pre>\n<p>If we treat file-level definitions as record fields, <code>import_sig</code> can infer a record type for a file and <code>import</code> can produce a value of that type.\nIn fact, these functions could just as easily take code strings, letting us generate code at compile time!</p>\n<h3 id=\"reflection\">Reflection</h3>\n<p>Syl already has rudimentary reflection via the built-in <code>repr</code> function, which returns metadata for a type.</p>\n<pre><code>fun print_ty (erased t : type) : unit =\n  match erased repr t {\n    .unit -&gt; print &quot;unit&quot;,\n    .bool -&gt; print &quot;bool&quot;,\n    .int -&gt; print &quot;int&quot;,\n    _ -&gt; unreachable,\n  }\n;;</code></pre>\n<p>This facility could be straightforwardly extended to allow inspecting the structure of types, which could then be used to implement typed Rust or OCaml-style derivers.</p>\n<h3 id=\"user-defined-layouts\">User-Defined Layouts</h3>\n<p>While fairly speculative, I think we should be able to define and manipulate types’ runtime layouts at compile time.\nWith such a feature, users could safely describe interoperable niche optimizations, possibly in the style of <a href=\"https://inria.hal.science/hal-04165615v1/document\" rel=\"nofollow ugc noopener\"><em>Bit-Stealing Made Legal</em></a>.\nThis would also require some notion of “unaddressable” fields that do not allow creating pointers to them.</p>\n<hr />\n<h2 id=\"everything-else\">Everything Else</h2>\n<p>Syl’s design is far from complete.\nIn particular, it needs stories for mode polymorphism, lifetimes, linearity, implicit parameters, and proofs.\nThe compiler needs incremental compilation, useful error traces, and an LLVM backend.\nI hope to share more thoughts on these areas in the future.</p>\n<hr />\n<h1 id=\"footnotes\">Footnotes</h1>\n<hr />\n<ol><li><p>In the literature, modal type systems are presented in terms of modalities.</p><p> We will describe modes as they appear in OxCaml, but you can translate by thinking of all values as <code>static unerased</code>\n until captured under a <code>dynamic</code> or <code>erased</code> modality. <a href=\"#fnref:1\">↩︎</a></p></li><li><p>Currently, staticity has modal axiom K, which allows staticity-preserving functions to operate</p><p> on dynamic arguments. I plan to replace the implicit application rule with mode polymorphism, which can also handle erasure. <a href=\"#fnref:2\">↩︎</a></p></li><li>This is the core idea of <a href=\"https://andraskovacs.github.io/pdfs/2ltt.pdf\" rel=\"nofollow ugc noopener\"><em>Staged Compilation with Two-Level Type Theory</em></a>, though it’s not presented as a modal type system. <a href=\"#fnref:3\">↩︎</a></li><li>This idea is formally explored in <a href=\"https://www.cis.upenn.edu/~sweirich/papers/msfp12prog.pdf\" rel=\"nofollow ugc noopener\"><em>Irrelevance, Heterogeneous Equality, and Call-by-value Dependent Type Systems</em></a>. Thanks to Chris for proofreading this post! <a href=\"#fnref:4\">↩︎</a></li><li>Syl does not yet have equality types, but once it does, erasure will provide a natural mechanism for forcing proof-producing computations when their results are needed. For example, <code>assert erased b</code> could establish <code>b = true</code> in the proof context by checking it reduces to <code>true</code>. <a href=\"#fnref:5\">↩︎</a></li><li>This isn’t a fair comparison, but it’s better! <a href=\"https://godbolt.org/z/zP3Y9PjMG\" rel=\"nofollow ugc noopener\">C++</a> / <a href=\"https://pastebin.com/BNrfcP8B\" rel=\"nofollow ugc noopener\">Syl</a>. <a href=\"#fnref:6\">↩︎</a></li><li>There exist ideas on how to make such modalities inferrable, but I don’t understand them yet. <a href=\"#fnref:7\">↩︎</a></li></ol>\n<hr />\n<p><em>Originally published at <a href=\"https://thenumb.at/Syl-Intro/\" rel=\"nofollow ugc noopener\">thenumb.at</a>.</em></p>","headings":[{"level":1,"text":"Compile-Time Dependent Types","id":"compile-time-dependent-types"},{"level":2,"text":"Modes","id":"modes"},{"level":2,"text":"Dependent Types","id":"dependent-types"},{"level":3,"text":"Proofs","id":"proofs"},{"level":3,"text":"Totality","id":"totality"},{"level":2,"text":"Examples","id":"examples"},{"level":3,"text":"Typed Interpreter","id":"typed-interpreter"},{"level":3,"text":"Length-Indexed Vector","id":"length-indexed-vector"},{"level":2,"text":"Extensions","id":"extensions"},{"level":3,"text":"Higher-Kinded Types","id":"higher-kinded-types"},{"level":3,"text":"Modules and Functors","id":"modules-and-functors"},{"level":3,"text":"Imports","id":"imports"},{"level":3,"text":"Reflection","id":"reflection"},{"level":3,"text":"User-Defined Layouts","id":"user-defined-layouts"},{"level":2,"text":"Everything Else","id":"everything-else"},{"level":1,"text":"Footnotes","id":"footnotes"}]}}