Skip to content
stxscript. GitHub

← Back to writing

Reading the StxScript → Clarity transpile output

StxScript Team · ·
transpileraudit

There is a quiet but important promise built into StxScript: the Clarity it emits is not minified, not obfuscated, not optimized into something only the compiler can read. It is indented, conventional Clarity that a Stacks developer can audit line by line. This post is a guide to actually doing that — taking a .stx file, running stxscript build, and reading the two files side by side without getting lost.

Why bother? Because the deliverable that ends up on-chain is the Clarity, not the StxScript. Auditors review the Clarity. Block explorers display the Clarity. The runtime executes the Clarity. If you cannot read it, you cannot truly review it, and “trust the transpiler” is not a security model anyone signs off on.

The mental shift: lowering, not translation

A useful frame before opening any output: StxScript does not “translate” your source. It lowers it, the same way an optimizing compiler lowers high-level constructs to a simpler IR. Every StxScript construct corresponds to one or more Clarity expressions. The mapping is documented and stable. Once you internalize the lowering rules, reading the output is mechanical — you can predict what each StxScript line becomes before you scroll to it.

The four-stage pipeline reinforces this. Parsing produces an AST. Semantic analysis annotates it with resolved types. Code generation walks the annotated AST and emits Clarity. There is no spooky action at a distance; every Clarity form you see comes from a specific node in the source tree.

Start with declarations

The easiest rules to learn are the declaration mappings. From the README’s language example:

const TOKEN_NAME: string = "MyToken";
const MAX_SUPPLY: uint = 1000000u;

let total_supply: uint = 0u;
map balances<principal, uint>;

becomes

(define-constant TOKEN_NAME u"MyToken")
(define-constant MAX_SUPPLY u1000000)
(define-data-var total_supply uint u0)
(define-map balances principal uint)

The mapping is one-to-one. const becomes define-constant; let becomes define-data-var; map becomes define-map. The type after the colon in StxScript becomes the type after the name in Clarity. The u suffix on integer literals in StxScript becomes the u prefix on Clarity literals. String literals get a u prefix in Clarity to mark them as UTF-8.

If you find a declaration whose output you cannot predict from this rule, file an issue. That is either a documentation bug or a code generation bug; either way it is interesting.

Functions: decorators are the secret

The second rule is about decorators. Public functions become define-public. Read-only functions become define-read-only. Plain functions become define-private. This is the cleanest example of why the surface syntax matters — instead of remembering three Clarity forms, you remember three TypeScript-style decorators that read naturally above the function body.

@public
function mint(amount: uint): Response<bool, uint> {
    if (total_supply + amount > MAX_SUPPLY) {
        return err(1u);
    }
    total_supply = total_supply + amount;
    return ok(true);
}

becomes

(define-public (mint (amount uint))
  (if (> (+ (var-get total_supply) amount) MAX_SUPPLY)
    (err u1)
    (begin
      (var-set total_supply (+ (var-get total_supply) amount))
      (ok true))))

Notice three things while reading:

  1. Parameters lift into the function signature. amount: uint in StxScript becomes (amount uint) in Clarity. Order and types are preserved.
  2. Mutable variable access becomes explicit. total_supply in StxScript becomes (var-get total_supply) in Clarity on read, and (var-set total_supply ...) on write. The transpiler tracks which identifiers are let declarations and inserts the right wrappers.
  3. Control flow lowers to nested expressions. The early return err(1u) does not become a Clarity return (there is no such form). It becomes the false branch of an if. The implicit return ok(true) at the end becomes the true branch. The transpiler reshapes imperative flow into Clarity’s expression-oriented model.

The third point is the most important to internalize. Clarity is expression-oriented; StxScript lets you write imperatively. The codegen pass smooths between the two. When you read an output if, find the StxScript return it came from — they are paired.

Match expressions, the other big lowering

The match form in StxScript is sugar for safely destructuring an Optional or a Response. The README example:

@readonly
function get_balance(account: principal): uint {
    match balances.get(account) {
        some(balance) => balance,
        none => 0u
    }
}

becomes

(define-read-only (get-balance (account principal))
  (default-to u0 (map-get? balances account)))

This is where the transpiler shines. The naive lowering of match would be a match form in Clarity (which exists), but the optimizer recognizes the specific pattern “give me the value if present, otherwise a default” and emits default-to. The output is shorter and more idiomatic than the rote translation.

When you read an output that uses default-to, unwrap!, try!, or asserts!, search the StxScript for the pattern that produced it:

  • ?? becomes default-to.
  • ! (postfix unwrap) becomes unwrap!.
  • try! stays try!.
  • if (cond) { return err(x); } becomes (asserts! cond (err x)).

The optimizer prefers these idioms over equivalents because they are how Stacks developers actually write Clarity. Your output looks hand-written because, in effect, it is — the transpiler embeds the conventions an experienced engineer would use.

Identifier casing: where the transpiler does take liberties

There is exactly one place the output is not a mechanical mapping: identifiers. Clarity convention uses kebab-case (get-balance), and the StxScript codegen rewrites snake_case function names to kebab-case at emit time. You can see this in the README example: get_balance in StxScript becomes get-balance in Clarity.

Variable and constant names are preserved as-is. Map names are preserved. Type names map according to the table in the docs. Only function names get the casing rewrite, and the rule is: snake_case to kebab-case, capitalize nothing.

If you are auditing and want to search across the two files, run a find on the StxScript name and a find on the kebab-cased version. They should appear in matching positions.

Practical reading workflow

Once the rules are familiar, a practical audit workflow looks like:

  1. Open the .stx file in the left pane of your editor.
  2. Open the .clar file in the right pane.
  3. Scroll both in lockstep. Each top-level declaration in StxScript corresponds to one top-level form in Clarity.
  4. Inside function bodies, find each return and locate it in the Clarity output. Most of the time it is the trailing position of the nearest enclosing branch.
  5. For any control flow you cannot map, run stxscript build --verbose (when available) and print the AST. The AST is the bridge — once you see the node, the codegen rule is obvious.

After a few contracts this becomes mechanical. After ten, you stop opening the .clar for routine reviews because you already know what it says.

When the output surprises you

If the Clarity surprises you, three possibilities (in descending likelihood):

  1. You forgot a lowering rule. Re-read the mapping table in the docs.
  2. The optimizer found a shorter idiom than you expected. This is good; learn the idiom.
  3. The transpiler has a bug. File an issue with the StxScript source, the Clarity output, and what you expected instead.

The third possibility is rare but the project is interested. As of v0.3.0 the test suite is 146 cases and growing — most of those tests started as someone’s surprised “wait, why does this lower like that?” moment.

The goal is a transpiler whose output you trust on first read. The way you get there is by reading enough output to know what to expect — and by filing issues when reality and expectation disagree.