Reading the StxScript → Clarity transpile output
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:
- Parameters lift into the function signature.
amount: uintin StxScript becomes(amount uint)in Clarity. Order and types are preserved. - Mutable variable access becomes explicit.
total_supplyin StxScript becomes(var-get total_supply)in Clarity on read, and(var-set total_supply ...)on write. The transpiler tracks which identifiers areletdeclarations and inserts the right wrappers. - Control flow lowers to nested expressions. The early
return err(1u)does not become a Clarityreturn(there is no such form). It becomes the false branch of anif. The implicitreturn 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:
??becomesdefault-to.!(postfix unwrap) becomesunwrap!.try!staystry!.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:
- Open the
.stxfile in the left pane of your editor. - Open the
.clarfile in the right pane. - Scroll both in lockstep. Each top-level declaration in StxScript corresponds to one top-level form in Clarity.
- Inside function bodies, find each
returnand locate it in the Clarity output. Most of the time it is the trailing position of the nearest enclosing branch. - 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):
- You forgot a lowering rule. Re-read the mapping table in the docs.
- The optimizer found a shorter idiom than you expected. This is good; learn the idiom.
- 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.