Skip to content

Conditionals and Script Size

JuLC supports familiar Java conditionals: if, else if, else, the ternary operator, and exhaustive switch expressions. You do not need to rewrite normal, readable Java just because the contract eventually runs as UPLC.

There is one on-chain detail worth knowing: code after a conditional may become part of every generated branch that can reach it. Usually this is small and harmless. If the shared code is large, however, the generated script can become larger than the Java source suggests.

This page shows how to keep validation code readable while avoiding unnecessary script size and execution cost.

  • An if does not need an else.
  • Nested if statements work, including several levels of nesting.
  • Code after a nested if runs only when execution reaches it, just as it does in Java.
  • Prefer a sequence of guard clauses for independent validation rules.
  • Put cheap checks before expensive checks so failures stop early.
  • Keep large common work outside branch bodies.
  • If several branches reach the same large block, extract that block into a helper method.
  • Prefer exhaustive switch expressions for sealed types such as redeemers.
  • Measure script size and execution budget; do not optimize based only on appearance.
  • Compute a value where it is declared instead of updating it across branches; see Value-Oriented Contract Code.

This is valid JuLC:

static boolean validate(BigInteger amount) {
if (amount.compareTo(BigInteger.ZERO) <= 0) {
return false;
}
return true;
}

When the condition is false, execution continues with return true. JuLC preserves that Java behavior when it creates the UPLC program.

The same applies to nested conditionals:

static boolean validate(BigInteger a, BigInteger b) {
if (a.compareTo(BigInteger.ZERO) > 0) {
if (b.compareTo(BigInteger.ZERO) <= 0) {
return false;
}
} else {
return false;
}
return true;
}

This returns true only when both a > 0 and b > 0. The inner if does not need an else: when b > 0, execution continues after the outer if.

Four or more nesting levels are also supported. Deep nesting is mainly a readability concern; it is not necessary to add artificial else branches for the compiler.

An if inside a loop carries updates to accumulators declared before that loop. It does not carry updates to locals declared in the loop body outside the branch. The latter pattern is rejected (#155), even for constant conditions or unused updates:

for (var x : xs) {
BigInteger step = BigInteger.ZERO;
if (x.compareTo(BigInteger.ZERO) > 0) {
step = BigInteger.ONE; // rejected: conditional update to a loop-body local
}
acc = acc.add(step);
}

Prefer an initializer that computes the conditional value:

for (var x : xs) {
BigInteger step = x.compareTo(BigInteger.ZERO) > 0
? BigInteger.ONE : BigInteger.ZERO;
acc = acc.add(step);
}

Alternatively declare step before the loop, making it an accumulator; reset it at the start of each iteration if needed to preserve its original lifetime. Value-Oriented Contract Code shows this and the other value-returning rewrites with tested examples. If the value is only needed inside the branch, declare it there instead:

for (var x : xs) {
if (x.compareTo(BigInteger.ZERO) > 0) {
BigInteger step = BigInteger.ZERO;
step = step.add(x);
acc = acc.add(step);
}
}
  • Locals must be initialized at declaration. Outside supported loop paths, ordinary reassignment is not supported.
  • In an accumulator-carrying loop without break, a local may be reassigned as a direct statement in its scope, including a bare nested block. Bare braces do not create a value join.
  • An if/else may update that loop’s accumulators. It may also contain straight-line updates to locals declared in the same branch; it may not update a loop-body local declared outside that branch. Another nested if introduces the same restriction again, even for constant conditions or dead updates.
  • A local declared before a nested loop may be an accumulator of that nested loop. This does not let its updates escape an enclosing if of the outer loop or a switch-expression arm.
  • break-aware support is narrower: the single-accumulator path does not support general straight-line reassignment of other body locals. Use an initializer for those locals. A branch-local declaration and reassignment can still work in an if whose branches contain no break, because it uses the normal body lowering.
  • Loops with no detected accumulator use the generic statement path and do not support general body-local reassignment either; prefer initializers.
  • Multiple accumulators must be Data-encodable; native BLS values cannot be packed with other accumulators. These restrictions apply to both for-each and while.

Switch expressions export values, not variable updates

Section titled “Switch expressions export values, not variable updates”

An arm cannot mutate a variable declared outside it, even through a nested loop:

BigInteger step = BigInteger.ZERO;
BigInteger ignored = switch (action) {
case Only o -> {
for (var y : xs) { step = step.add(y); } // rejected
yield BigInteger.ZERO;
}
};

Instead, declare the accumulator in the arm and yield its result:

BigInteger step = switch (action) {
case Only o -> {
BigInteger local = BigInteger.ZERO;
for (var y : xs) { local = local.add(y); }
yield local;
}
};

The restriction includes outer-loop accumulators, not just loop-body locals, and does not require an enclosing if. Nested switch arms introduce their own boundary.

Reassigning a case-pattern variable itself (case Only p -> ... p = ...) is now rejected (#162): cached field reads could otherwise use the original record. Copy it to a fresh arm-local accumulator and update that copy instead. This is conservative even if you yield the record without reading its fields. It does not prohibit otherwise-supported reassignment of an instanceof binding; those bindings use a different lowering path.

A loop inside an if at method level or inside a switch-expression arm preserves its accumulator updates for statements after the branch. This fixes #161, separately from #157’s restrictions on loop-body locals and switch boundaries. For example:

BigInteger total = BigInteger.ZERO;
if (xs.size().compareTo(BigInteger.ZERO) > 0) {
for (var x : xs) { total = total.add(x); }
}
return total; // [1, 2, 3]: returns 6; empty input: returns 0

This also works inside a switch arm with an arm-local total and yield total. The switch expression may itself be inside an outer loop: the arm yields its result, and the outer loop consumes that value. The arm still cannot update variables declared outside it. Untaken branches preserve the previous value; for-each/while, nested branches, multiple accumulators and break retain their existing loop semantics.

The empty-list check above is optional because an empty for-each runs zero iterations. Keep conditions that affect intended behavior. Recompile affected scripts and reassess their hashes and budgets; previously deployed scripts are not repaired by upgrading the compiler.

A validator often checks several independent rules. A linear sequence is usually the clearest way to express them:

record PaymentDatum(byte[] owner, BigInteger amount) {}
static boolean validate(PaymentDatum datum, ScriptContext ctx) {
TxInfo txInfo = ctx.txInfo();
if (datum.amount().compareTo(BigInteger.ZERO) <= 0) {
return false;
}
if (!ContextsLib.signedBy(txInfo, datum.owner())) {
return false;
}
if (!hasRequiredPayment(txInfo, datum)) {
return false;
}
return true;
}

This style has three useful properties:

  1. Each rule is visible on its own.
  2. A failed rule stops evaluation before later work.
  3. There is no deeply nested success path to read.

The equivalent nested form is harder to scan:

if (amountIsValid(datum)) {
if (ownerSigned(txInfo, datum)) {
if (hasRequiredPayment(txInfo, datum)) {
return true;
}
}
}
return false;

Both forms can be correct, but guard clauses communicate validator intent more directly.

Java’s if, &&, and || retain their short-circuit behavior in JuLC. Work that is not reached is not evaluated.

Use that to reject invalid transactions before running expensive operations:

if (amount.compareTo(BigInteger.ZERO) <= 0) {
return false; // cheap integer comparison
}
if (!ContextsLib.signedBy(txInfo, owner)) {
return false; // list search
}
return verifyProof(proof, expectedRoot); // expensive work last

The order must still preserve the contract’s meaning. Do not reorder checks when one depends on data validated by an earlier check.

Suppose mint and burn actions have different rules, followed by several common checks:

if (isMint) {
if (!validMint(action, txInfo)) {
return false;
}
} else {
if (!validBurn(action, txInfo)) {
return false;
}
}
TxInfo info = ctx.txInfo();
var outputs = info.outputs();
var signatories = info.signatories();
// ...many more common calculations...
return validateCommonRules(outputs, signatories);

At the UPLC level there is no Java-style instruction pointer that simply moves to the next statement. JuLC represents the remaining work as an expression—often called the continuation—and connects it to every branch that can continue.

If several branches can continue into a large block, some of that generated structure may be repeated. The result remains correct, but its serialized script can be larger.

A helper keeps the shared body in one generated binding:

static boolean validate(Action action, ScriptContext ctx) {
TxInfo txInfo = ctx.txInfo();
if (isMint(action)) {
if (!validMint(action, txInfo)) {
return false;
}
} else {
if (!validBurn(action, txInfo)) {
return false;
}
}
return validateCommonRules(txInfo);
}
static boolean validateCommonRules(TxInfo txInfo) {
var outputs = txInfo.outputs();
var signatories = txInfo.signatories();
// ...the large common calculation exists here once...
return outputsAreValid(outputs) && signersAreValid(signatories);
}

The helper call may appear on more than one generated path, but the helper’s body is defined once. This matters most when the common block is large; extracting every two-line continuation usually makes code less readable without a meaningful size win.

For a sealed redeemer or another sum type, use a Java switch expression that produces a value:

boolean actionIsValid = switch (action) {
case Mint mint -> validMint(mint, txInfo);
case Burn burn -> validBurn(burn, txInfo);
};
if (!actionIsValid) {
return false;
}
return validateCommonRules(txInfo);

JuLC switch expressions must be exhaustive: handle every permitted variant or provide a default branch. Statement-style switches with return inside cases are not the supported pattern; produce a value with a switch expression instead.

For a multi-statement case, use yield:

boolean actionIsValid = switch (action) {
case Mint mint -> {
boolean positive = mint.amount().compareTo(BigInteger.ZERO) > 0;
yield positive && validMint(mint, txInfo);
}
case Burn burn -> validBurn(burn, txInfo);
};

Do not move an operation before a guard merely to make the generated program look smaller:

// Risky: head() is evaluated before the empty-list check.
TxOut first = outputs.head();
if (outputs.isEmpty()) {
return false;
}

Keep the guard first:

if (outputs.isEmpty()) {
return false;
}
TxOut first = outputs.head();

UPLC evaluation is strict. Moving head() earlier makes it run even for an empty list, where it can fail. The same warning applies to division, decoding data, indexing lists, and other operations that require validated input.

Control-flow shape is only one part of script size. Compile the validator and inspect the actual result:

CompileResult compiled = ValidatorTest.compileValidator(MyValidator.class);
System.out.println(compiled.scriptSizeFormatted());
System.out.println(compiled.scriptSizeBytes());

For important contracts, add a regression assertion:

BudgetAssertions.assertScriptSizeUnder(compiled, 16_384);

Measure execution budget with representative successful and failing transactions too. A smaller script is not automatically cheaper on every execution path. See the Testing Guide for budget tests and script analysis.

Before optimizing a validator, ask:

  • Can independent validation rules be written as guard clauses?
  • Are cheap and safe rejection checks performed before expensive work?
  • Does a large block follow several branches that can all continue?
  • Would one well-named helper keep that common block in one place?
  • Am I preserving guards before operations that can fail?
  • Have I measured both serialized script size and execution budget?