Skip to main content

Variants

Variants are tagged unions that represent values from a fixed set of alternatives. Each alternative has a unique tag (starting with #) and can optionally carry a payload. Variants are the standard way to model enumerations, result types, and state machines in Sector9.

Creating Variants

Define variant types by listing tags with optional payload types:

variant-basic.sr9
// Basic variant types and construction
persistent actor {
// Define a variant type with three cases
type Status = { #pending; #approved; #rejected };

// Variant with payloads of different types
type Result = { #ok : Nat; #err : Text };

// Variant with tuple payload
type Color = { #red; #blue : Nat; #green : (Nat, Nat) };

var status : Status = #pending;

// Construct variants with their tag
public func setPending() : async ()
modifies status
ensures status == #pending;
{
status := #pending
};

public func setApproved() : async ()
modifies status
ensures status == #approved;
{
status := #approved
};

// Construct payloaded variant
public func makeOk(n : Nat) : async Result
ensures result == #ok(n);
{
#ok(n)
};

public func makeErr(msg : Text) : async Result
ensures result == #err(msg);
{
#err(msg)
};
}

Key points:

  • Tags start with # followed by an identifier: #pending, #approved, #rejected
  • Tags without payloads: #pending (just the tag itself)
  • Tags with payloads: #ok : Nat (tag carries a value)
  • Tuple payloads: #green : (Nat, Nat) (tag carries multiple values)
  • Construct variants by writing the tag with its payload: #ok(42), #err("failed")

Pattern Matching with switch

Use switch expressions to match on variant tags and extract payloads:

variant-switch.sr9
// Pattern matching on variants with switch
persistent actor {
type Status = { #pending; #approved; #rejected };
type Result = { #ok : Nat; #err : Text };

// Switch on simple variant
public func statusCode(s : Status) : async Nat
ensures switch (s) {
case (#pending) result == 0;
case (#approved) result == 1;
case (#rejected) result == 2;
};
{
switch (s) {
case (#pending) 0;
case (#approved) 1;
case (#rejected) 2;
}
};

// Extract payload with pattern binding
public func getValue(r : Result) : async Nat
ensures switch (r) {
case (#ok n) result == n;
case (#err _) result == 0;
};
{
switch (r) {
case (#ok n) n;
case (#err _) 0;
}
};

// Wildcard matches remaining cases
public func isApproved(s : Status) : async Bool
ensures s == #approved ==> result == true;
ensures s != #approved ==> result == false;
{
switch (s) {
case (#approved) true;
case (_) false;
}
};
}

Pattern matching rules:

  • Each case matches a tag: case (#pending) ...
  • Bind payload to a variable: case (#ok n) ... binds n to the payload
  • Use _ to ignore a payload: case (#err _) ...
  • Wildcard case (_) matches all remaining cases
  • All cases must be covered (exhaustiveness is checked at compile time)

Payloads

Variants can carry different types of data:

variant-payload.sr9
// Variants with different payload types
persistent actor {
// No payload
type Empty = { #none };

// Single value payload
type MaybeInt = { #nothing; #just : Int };

// Tuple payload
type Point = { #origin; #coords : (Int, Int) };

// Multiple variants with different payloads
type Shape = { #circle : Nat; #rect : (Nat, Nat); #point };

// Pattern match to extract tuple payload
public func area(s : Shape) : async Nat
ensures switch (s) {
case (#circle r) result == r * r;
case (#rect (w, h)) result == w * h;
case (#point) result == 0;
};
{
switch (s) {
case (#circle r) r * r;
case (#rect (w, h)) w * h;
case (#point) 0;
}
};

// Extract single payload
public func getInt(m : MaybeInt) : async Int
ensures switch (m) {
case (#nothing) result == 0;
case (#just n) result == n;
};
{
switch (m) {
case (#nothing) 0;
case (#just n) n;
}
};
}

Payload types:

PatternSyntaxExample
No payload#tag#pending, #rejected
Single value#tag : Type#ok : Nat, #err : Text
Tuple#tag : (T1, T2)#coords : (Int, Int)

When matching tuple payloads, destructure directly: case (#coords (x, y)).

Pure Functions with Variants

Pure functions work naturally with variants for reusable predicates:

variant-pure.sr9
// Pure functions with variants
persistent actor {
type Result = { #ok : Nat; #err : Text };
type Status = { #pending; #approved : Nat; #rejected };

// Pure function to check variant case
pure func isOk(r : Result) : Bool {
switch (r) {
case (#ok _) true;
case (#err _) false;
}
};

// Pure function to extract payload with default
pure func getOrDefault(r : Result, default : Nat) : Nat {
switch (r) {
case (#ok n) n;
case (#err _) default;
}
};

// Pure function to check specific case
pure func isPending(s : Status) : Bool {
switch (s) {
case (#pending) true;
case (_) false;
}
};

// Use pure functions in postconditions
public func checkResult(r : Result) : async Bool
ensures result == isOk(r);
{
isOk(r)
};

public func unwrapOr(r : Result, default : Nat) : async Nat
ensures result == getOrDefault(r, default);
{
getOrDefault(r, default)
};
}

Pure variant helpers like isOk and getOrDefault can appear directly in ensures clauses, making contracts more readable.

Variants as State

Variants are ideal for modeling state machines:

variant-state.sr9
// Variants as actor state
persistent actor {
type Status = { #pending; #approved : Nat; #rejected };

var status : Status = #pending;

// State transitions with preconditions
public func approve(id : Nat) : async ()
modifies status
entry_requires status == #pending;
requires status == #pending;
ensures status == #approved(id);
{
status := #approved(id)
};

public func reject() : async ()
modifies status
entry_requires status == #pending;
requires status == #pending;
ensures status == #rejected;
{
status := #rejected
};

public func reset() : async ()
modifies status
ensures status == #pending;
{
status := #pending
};

// Query current state
public func getStatus() : async Status
reads status
ensures result == status;
{
status
};

// Check specific state
public func isPending() : async Bool
reads status
ensures result == (status == #pending);
{
switch (status) {
case (#pending) true;
case (_) false;
}
};
}

State machine patterns:

  • Store variant in actor field: var status : Status = #pending
  • Use entry_requires and requires to enforce valid public transitions: entry_requires status == #pending
  • Use ensures to specify the new state: ensures status == #approved(id)
  • Payload captures transition data: #approved(id) remembers the approval ID

Contracts with Variants

Specify behavior per variant case using switch in postconditions:

public func describe(s : Status) : async Text
ensures switch (s) {
case (#pending) result == "waiting";
case (#approved n) result == "approved";
case (#rejected) result == "denied";
};
{
switch (s) {
case (#pending) "waiting";
case (#approved _) "approved";
case (#rejected) "denied";
}
}

For result-style variants, use implication (==>) for cleaner contracts:

public func divide(a : Nat, b : Nat) : async Result
ensures b == 0 ==> result == #err("division by zero");
ensures b != 0 ==> result == #ok(a / b);

Verification Behavior

The verifier handles variants by generating:

  1. Discriminator functions: Check which tag a value has (isResult$ok(r))
  2. Getter functions: Extract payloads (getResult$ok$0(r))
  3. Exhaustiveness axioms: Ensure exactly one tag is active
  4. Exclusivity axioms: Tags are mutually exclusive

This means postconditions like switch (r) { case (#ok n) ... } work correctly: the verifier knows that if isResult$ok(r) is true, then isResult$err(r) is false.

Common Patterns

Result Type

Model success/failure with payloads:

type Result<T, E> = { #ok : T; #err : E };

public func safeDivide(a : Nat, b : Nat) : async Result<Nat, Text>
ensures b == 0 ==> switch (result) { case (#err _) true; case _ false }
ensures b != 0 ==> result == #ok(a / b);
{
if (b == 0) { #err("division by zero") } else { #ok(a / b) }
};

Option Alternative

While ?T is the idiomatic option type, variants offer explicit naming:

type MaybeInt = { #nothing; #just : Int };

State Machines

Model protocols with restricted transitions:

type Loan = { #applied : Nat; #approved : (Nat, Nat); #denied };

public func approve(rate : Nat) : async ()
modifies loan
entry_requires switch (loan) { case (#applied _) true; case _ false };
requires switch (loan) { case (#applied _) true; case _ false };
ensures switch (old(loan)) {
case (#applied amt) loan == #approved(amt, rate);
case _ false
};
{
switch (loan) {
case (#applied amt) { loan := #approved(amt, rate) };
case _ { assert false }
}
}

Restrictions

Exhaustiveness Required

All variant cases must be covered. Missing cases cause a compile error. See pattern matching for the general exhaustiveness rules:

switch (status) {
case (#pending) 0;
case (#approved) 1;
// ERROR M0145: pattern does not cover value #rejected
}

No Or-Patterns

Combining cases with or is not supported:

// NOT SUPPORTED
case (#pending or #rejected) 0;

Use wildcards or list cases separately:

case (#pending) 0;
case (#rejected) 0;
case (#approved) 1;

Function Payloads

Function types in variant payloads must be pure or contract-typed in verification mode:

type Callback = { #none; #some : (Nat -> Nat) };  // Function must be pure or contract-typed

Mutable Records in Payloads

Mutable records can appear in variant payloads, but have ownership implications:

type Cell = { var x : Int };
type MaybeCell = { #none; #some : Cell };

switch (v) {
case (#some c) {
c.x := 10; // Mutation allowed after pattern match
};
case (#none) {};
};

The pattern match introduces a local alias to the mutable record. Ownership and aliasing are still checked by the verifier; avoid keeping multiple mutable aliases live when you need to write through one of them.

Error Detection

The verifier catches incorrect postconditions:

variant-wrong_unsat.sr9
// Wrong postcondition: verifier will reject
// Expected: verification failure (_unsat suffix)
persistent actor {
type Color = { #red; #blue : Nat };

public func colorCode(c : Color) : async Nat
ensures switch (c) {
case (#red) result == 5; // WRONG: actual returns 4
case (#blue x) result == x;
};
{
switch (c) {
case (#red) 4; // Returns 4, not 5
case (#blue x) x;
}
};
}

The verifier rejects this because #red returns 4, not 5. Verification failures appear as unsat results.

Variant vs Option

FeatureVariantOption (?T)
Syntax{ #none; #some : T }?T
Construction#some(value)?value
Empty#nonenull
Custom namesYesNo
Multiple emptiesYesNo

Use ?T for simple nullable values. Use variants when you need custom tag names or multiple "empty" states.

Variant vs Record

FeatureVariantRecord
PurposeOne of several alternativesCollection of named fields
ContainsOne tag with optional payloadAll fields simultaneously
AccessPattern matchingDot notation
Example#ok(42) or #err("fail"){ x = 1; y = 2 }

Variants model "or" (alternatives). Records model "and" (all fields together).

Error Codes

CodeMeaning
M0116Variant pattern cannot consume expected type
M0145Pattern does not cover all variant cases
M0146Pattern is never matched (unreachable)
M0360Higher-order function in variant must be pure or contract-typed

Summary

  • Variants represent one of several tagged alternatives
  • Tags start with #: #pending, #ok : Nat, #coords : (Int, Int)
  • Use switch to pattern match and extract payloads
  • Bind payloads to variables: case (#ok n) ...
  • All cases must be covered (exhaustiveness checked at compile time)
  • Pure functions with variants work in postconditions
  • Variants model state machines with requires/ensures on transitions
  • Use implication (==>) for result-style contracts
  • The verifier generates discriminator and getter functions automatically
  • Prefer ?T for simple nullability, variants for richer alternatives