4. Functions¶
We've been leaning on main, plus a few built-ins like println. Now we write our own
functions. The mechanics are familiar from any language — name, parameters, body — but
Sushi adds one twist that touches every function you'll ever write: the return value is
quietly wrapped in a Result. This chapter explains that wrapping and shows how to use
the values functions hand back.
Declaring and calling¶
A function starts with fn, a name, a parenthesised parameter list, and a return type.
Each parameter is written type-first, exactly like a variable declaration: i32 a. The
body is indented under a colon:
fn add(i32 a, i32 b) i32:
return Result.Ok(a + b)
fn greet(string name) ~:
println("Hello, {name}! Welcome aboard the Heart of Gold.")
return Result.Ok(~)
fn main() i32:
let i32 sum = add(40, 2).realise(0)
println("40 + 2 = {sum}")
greet("Arthur")
greet("Ford")
return Result.Ok(0)
Output:
40 + 2 = 42
Hello, Arthur! Welcome aboard the Heart of Gold.
Hello, Ford! Welcome aboard the Heart of Gold.
add takes two i32s and reports an i32. greet returns the blank type ~ from
Chapter 2 because it only prints. Notice both functions end
with return Result.Ok(...), and add's result is unwrapped with .realise(0) before we
print it. That's the twist worth slowing down for.
Everything returns a Result¶
In Chapter 1 we saw main end with return Result.Ok(0). That
wasn't special to main. Every Sushi function returns a Result — a value that is
either a success (Result.Ok(value)) or a failure (Result.Err(error)).
When you write a function whose return type looks like a plain i32:
fn add(i32 a, i32 b) i32:
the compiler reads that i32 as shorthand and actually gives you back a
Result<i32, StdError>. The i32 is the success type; StdError is a built-in default
error type. That's why even add, which never fails, still has to say Result.Ok(a + b).
There is no way to "just return an int" — success is always announced explicitly.
This sounds heavy, but it's the foundation of Sushi's promise that you can't accidentally ignore an error. Chapter 6 is devoted to making it ergonomic; for now we just need to consume the results.
Why force Result.Ok?
Making success explicit means the compiler always knows where errors can appear, and
can insist you handle them. The cost is one Result.Ok(...) per return; the payoff is
a whole class of bugs that simply can't compile.
Consuming a Result in main¶
A function returns a Result, so the caller has to open the box. There's a tempting
operator, ??, that unwraps it in one character — but using ?? inside main triggers a
warning (CW2511), so in main we use safer, explicit tools instead. (?? is perfectly
fine in other functions, as you'll see in the next chapter.)
Two everyday techniques work well in main:
if (result):treats theResultas a condition — theifbranch runs on success, theelsebranch on failure. Inside the success branch,result.realise(default)pulls out the value..realise(default)unwraps a success directly, substitutingdefaultif it was an error.
fn safe_divide(i32 a, i32 b) i32:
if (b == 0):
return Result.Err(StdError.Error)
return Result.Ok(a / b)
fn main() i32:
let Result<i32, StdError> good = safe_divide(42, 6)
if (good):
println("42 / 6 = {good.realise(0)}")
else:
println("Could not divide.")
let Result<i32, StdError> bad = safe_divide(42, 0)
if (bad):
println("This will not print.")
else:
println("Division by zero refused, as it should be.")
let i32 fallback = safe_divide(10, 0).realise(-1)
println("With a default: {fallback}")
return Result.Ok(0)
Output:
42 / 6 = 7
Division by zero refused, as it should be.
With a default: -1
safe_divide returns Result.Err(StdError.Error) when asked to divide by zero. The first
call succeeds, so if (good): runs its success branch. The second fails, so its else
branch runs. The last line shows .realise(-1) standing in -1 because the division
failed. At no point did we touch ??, and at no point could we have forgotten the failure
case.
Custom error types¶
StdError is the default, but a function can declare its own error type with the
T | ErrorType syntax: the part before the | is the success type, the part after is the
error type. That makes failures self-documenting.
enum NavError:
NoFuel
UnknownDestination
fn jump(string destination, i32 fuel) string | NavError:
if (fuel <= 0):
return Result.Err(NavError.NoFuel)
if (destination == ""):
return Result.Err(NavError.UnknownDestination)
return Result.Ok("Jumping to {destination}")
fn main() i32:
let Result<string, NavError> trip = jump("Magrathea", 100)
if (trip):
println(trip.realise("nowhere"))
else:
println("The jump failed.")
let Result<string, NavError> stranded = jump("Magrathea", 0)
if (stranded):
println(stranded.realise("nowhere"))
else:
println("The jump failed: out of fuel.")
return Result.Ok(0)
Output:
Jumping to Magrathea
The jump failed: out of fuel.
Here jump returns string | NavError, i.e. Result<string, NavError>, and can fail in
two named ways. We consume it in main with the same if (result): pattern as before.
This is only a taste — designing error types, propagating them with ??, and pattern
matching on the specific failure is the subject of
Chapter 6.
public functions¶
By default a function is private to its file. Marking it public makes it part of the
file's exported surface, so other units in a multi-file project can call it. The syntax is
just the keyword public in front of fn:
public fn area(i32 width, i32 height) i32:
return Result.Ok(width * height)
fn perimeter(i32 width, i32 height) i32:
return Result.Ok(2 * (width + height))
fn main() i32:
let i32 a = area(6, 7).realise(0)
let i32 p = perimeter(6, 7).realise(0)
println("area: {a}")
println("perimeter: {p}")
return Result.Ok(0)
Output:
area: 42
perimeter: 26
In a single-file program like this, public makes no practical difference — but it's the
habit you'll want once your programs grow past one file.
What you learned¶
- Declare functions with
fn name(Type param, ...) ReturnType:and call them by name. - Every function returns a
Result. A bare return type likei32is shorthand forResult<i32, StdError>, so every path must end inResult.Ok(...)orResult.Err(...). - In
main, consume aResultwithout??: useif (result):/else:and.realise(default). - Declare a custom error type with
fn foo() T | ErrorType— explored fully in Chapter 6. public fnexports a function for use by other units.
Functions give us reusable building blocks. Next we look closely at the type we've been printing all along: text. On to Strings.