Skip to content
stxscript. GitHub

← Back to writing

Type inference rules that map cleanly to Clarity's type system

StxScript Team · ·
typescompiler

A common reaction from developers who learn StxScript is something like: “wait, why does the type system not let me do X?” The answer is almost always the same — because Clarity does not let you do X, and StxScript refuses to promise anything its target cannot deliver. This post is about the design discipline behind that refusal, and the inference rules that let StxScript still feel like a modern statically-typed language despite the constraints.

The envelope: Clarity’s types are the floor and the ceiling

Clarity has a small, well-defined type system. Primitive types are int, uint, bool, principal, and bounded strings and buffers. Compound types are (optional T), (response T E), (list N T), (buff N), tuples {a: T, b: U}, and (string-utf8 N). There is no inheritance, no class hierarchy, no recursive types, no traits at the type level (Clarity traits are a runtime/contract-level concept).

StxScript’s type system mirrors all of this exactly. Every StxScript type has a Clarity counterpart:

  • intint
  • uintuint
  • boolbool
  • string(string-utf8 N)
  • principalprincipal
  • buffer<N>(buff N)
  • Optional<T>(optional T)
  • Response<T, E>(response T E)
  • List<T>(list N T)
  • { a: T, b: U }{ a: T, b: U }

That correspondence is enforced at the codegen layer: there is literally no path for the transpiler to emit a Clarity type that is not in the right column. If you try to use a type StxScript does not recognize, the semantic analyzer rejects the program before code generation ever runs.

The result is a useful guarantee: if the StxScript type checker accepts your program, the Clarity output will type-check at deploy time too.

Where inference actually happens

Most of the time, StxScript does not need to infer anything — annotations are present on declarations. let balance: uint = 0u; does not require inference. Neither does function transfer(from: principal, to: principal, amount: uint): Response<bool, uint> — both parameters and return are stated.

Inference matters in three places:

  1. Expression context. When you write total_supply + amount, the analyzer determines the result type by looking at the operands. Both are uint, so the result is uint. If the operands disagree, you get a type error pointing at the offending position.
  2. Literal narrowing. A bare 0 is ambiguous — it could be int or uint. The u suffix (0u) disambiguates. In contexts where the expected type is known (e.g. assigning to a uint variable), the analyzer can sometimes accept an unsuffixed literal, but the safest practice is always to suffix. The transpiler is conservative: when in doubt, it asks rather than guesses.
  3. Match arms. In match opt { some(x) => x, none => 0u }, the type of x is inferred from the type of opt. If opt: Optional<uint>, then x: uint. The arms must agree on result type — if one arm returns uint and the other returns bool, the analyzer rejects the match.

That is essentially it. There is no Hindley-Milner-style global inference; there is no row polymorphism; there is no subtyping to thread. The discipline keeps inference predictable, which is what you want when the output is supposed to be auditable.

Generics: monomorphization, not erasure

Generics are the most interesting type-system feature in StxScript because Clarity has none. The README mentions function identity<T>(x: T): T as a supported form. The question is: what does Clarity see?

The answer is monomorphization. At compile time, the analyzer collects every call site of a generic function and instantiates a specialized version for each set of type arguments actually used. If you call identity with uint once and principal twice, the codegen emits two private Clarity functions: identity-uint and identity-principal. The generic-ness is erased before the Clarity file is written.

This has consequences worth knowing:

  • Generic code that is never called produces no Clarity output.
  • Excessive generic instantiation expands contract size. A trait method called with five different concrete types becomes five Clarity functions.
  • The transpiler can warn (with stxscript lint) when monomorphization produces a contract larger than a configurable threshold.

The alternative — erasure with runtime type tags — would require Clarity to have dynamic typing, which it doesn’t. Monomorphization is the only correct choice given the target.

Type aliases: pure compile-time

Type aliases are even simpler. type Amount = uint; adds an entry to the analyzer’s symbol table. Every reference to Amount is resolved to uint. The Clarity output never mentions Amount because the alias never exists at the Clarity layer.

Type aliases are documentation that the type system enforces. They make function signatures readable — function transfer(from: principal, to: principal, amount: Amount): Response<bool, uint> reads better than the all-primitive version — without inventing a new runtime type. The transpiler will reject mixing Amount and uint only if the alias is declared as a distinct type, but in v0.3.0 type aliases are transparent (a synonym, not a newtype). That matches the README’s “type aliases” feature exactly.

Optional and Response: where the type system pays for itself

If there is one place the static type system saves the most pain, it is Optional<T> and Response<T, E>.

Optional<T> corresponds to Clarity’s (optional T). You construct values with some(x) or none, and you destructure with match. The analyzer enforces that you cannot use an Optional<uint> where a uint is expected — you have to unwrap, either with match, ?? (default), ! (unwrap, may abort), or try!.

Response<T, E> is the same idea for fallible operations. Public functions in Clarity must return a Response, and StxScript enforces this at the type level: if you declare @public function foo(): Response<bool, uint>, the analyzer will not accept a function body that returns a bare bool or a bare uint. The README’s mint example shows the pattern — every code path ends with ok(...) or err(...).

The benefit is that whole categories of Clarity deploy-time errors become StxScript editor errors. The LSP underlines them. You see them before stxscript build runs. The Clarity output type-checks because the StxScript source already did.

When Clarity’s type system is stricter

There is one direction the analogy breaks: Clarity’s string and buffer types are bounded. (string-utf8 N) carries the length in the type. (buff N) does the same. StxScript handles this by inferring or accepting the bound at declaration time and emitting it into the Clarity output.

let name: string = "Hello"; does not say 5, but the codegen counts the literal and emits (define-data-var name (string-utf8 5) u"Hello"). For buffers, the type is explicit (buffer<32>), and the analyzer rejects literals that do not fit.

If you assign a longer string later — say through a function parameter — the analyzer either widens the type to a known explicit bound or rejects the assignment. There is no implicit widening across declaration boundaries; you state the bound, or the analyzer derives it from the initial value, but nothing changes silently.

The point of the discipline

Why constrain the type system this hard? Because the alternative is the worst possible failure mode for a transpiler: a source program that type-checks but produces Clarity that does not deploy. If StxScript said yes to programs Clarity says no to, every user would spend their first week debugging the gap.

Instead, StxScript says no to everything Clarity says no to, before you ever run stxscript build. The error messages are in the source language, with positions pointing at the line you wrote. The Clarity output, when it exists, deploys.

That is the entire point of the type system: not novelty, not expressiveness for its own sake, but a fast, reliable filter that catches the things Clarity will refuse to run. Every inference rule is in service of that filter, and any rule that would let through programs Clarity rejects is excluded by construction.