Nanyx

Functions

Defining and calling functions

Functions are first-class values in Nanyx, meaning they can be passed as arguments, returned from other functions, and stored in data structures.

Functions are written with braces, { ... }, and optionally define parameters: { x -> ... }. The parameter list is a comma-separated list of names, and the body is an expression that computes the result.

Function basics

A function that takes no parameters (or, more specifically, take unit as input) is simply { ... }:

def main = {
  println("Hello, world!")
}

You could also write it as

def main = { () ->
  println("Hello, world!")
}

But this is almost never preferred, since the unit parameter list is implied when no parameters are specified.

All functions in Nanyx take exactly one argument and return exactly one result. The function type signature is a -> b, where a is the input type and b is the output type.

-- Simple function
def double: int -> int = { x -> x * 2 }

-- Calling the function
def result = double(21)  -- 42

Multi-parameter functions

Since all functions take one argument, multi-parameter functions actually take a record (or tuple, see Records and tuples) as their argument:

def add: (int, int) -> int = { x, y -> x + y }
-- The type (int, int) -> int means: takes a record of two ints, returns an int

-- Calling with multiple arguments
def sum = add(5, 10)  -- 15

Unit type

The () type (unit) represents the absence of a meaningful value. It’s used for functions that don’t take input or don’t return output:

-- No meaningful return value
def printHello: () -> () = {
  println("Hello")
  -- Don't need to write `return ()`
}

-- No parameters
def getMessage: () -> string = {
  "Hello, World!"
}

Lambda expressions

Lambda expressions (also called anonymous functions) are created with braces:

-- Explicit lambda
def print1 = { print("1") }

-- Lambda with parameter
def increment = { x -> x + 1 }

-- Lambda in higher-order function
def names = data \map { item -> item.name }

Shorthand lambdas

Nanyx provides convenient shorthand syntax for common lambda patterns, including member access and binary operators:

-- Property access
def names = users \map { .name }
-- Equivalent to: users \map { user -> user.name }

-- Binary operators
def doubled = numbers \map { * 2 }
-- Equivalent to: numbers \map { x -> x * 2 }

def incremented = numbers \fold(0) { + }
-- Equivalent to: numbers \fold(0) { x, y -> x + y }

-- Comparison operators
def adults = users \filter { .age > 18 }
-- Equivalent to: users \filter { user -> user.age > 18 }

Parameters

Optional parameters

Suffix a parameter name with ? to make it optional. If the parameter would otherwise have type T, its type becomes #some(T) | #none, and #none is passed when the caller omits it.

def printMessage = { message? ->
  message
  \match
    | #none -> "You didn't say anything"
    | #some(msg) -> "You said -> {msg}"
  \println
}

Default values

A parameter with a default value is also optional at the call site, but inside the function it has its plain type, with the default used when the caller omits it:

def printResults = { left = "left", middle, right = "right" ->
  println("{left}, {middle}, {right}")
}

printResults(middle = "centre")
-- left, centre, right

Parameters with defaults are named fields, so callers pass them by name (see Named parameters).

Named parameters

If you name parameters in the signature, callers must pass those fields by name, and the function body must destructure them by name too. When the local name is the same as the field name, field = field can be shortened to just field:

def replace: (string, pattern: string, replacement: string) -> string
  = { s, pattern, replacement -> ??? }

replace("Hello world", pattern = "world", replacement = "Nanyx")

This is one rule for every call and every record construction: a positional field is passed positionally, a named field is passed by name. See Positional vs named inference for how this interacts with unannotated functions.

Annotating lambda parameters

A lambda’s parameters and result can be annotated inline when inference needs a nudge or when the types document intent:

def double = { (x: int): int -> x * 2 }

Named fields can be destructured to a different local name with field = localName:

def magnitude: (x: int, y: int) -> float = { x = innerX, y = innerY ->
  Math.sqrt(innerX * innerX + innerY * innerY)
}

Callable values

Any value with a function type can be called like a function. A dictionary is one such value: its type is k -> #some(v) | #notFound, and calling it is a lookup.

def dict: string -> #some(string) | #notFound =
  ["Hello" => "Bonjour", "Goodbye" => "Au revoir"]

dict("Hello") -- #some("Bonjour")
dict("Hola")  -- #notFound

Higher-order functions

Functions that take other functions as arguments or return functions are called higher-order functions:

-- Takes a function as an argument
def apply: ((a -> b), a) -> b = { f, x -> f(x) }

-- Returns a function
def makeAdder: int -> (int -> int) = { x ->
  { y -> x + y }
}

def add5 = makeAdder(5)
def result = add5(10)  -- 15

Pattern-matching functions

Since a function that consists only of a pattern match on its arguments is so common, a shorthand syntax is available where functions can pattern match directly on their arguments:

-- Simple pattern matching function
def sumList: list(int) -> int = {
  | [] -> 0
  | [head, ...tail] -> head + sumList(tail)
}

-- Multiple arguments with patterns
def divide: (int, int) -> Result(int, #divideByZero) = { 
  | _, 0 -> #error(#divideByZero)
  | x, y -> #some(x / y)
}

-- Pattern matching with guards
def classify: int -> string = {
  | 0 -> "zero"
  | 1 -> "one"
  | n & { < 0 } -> "negative: {n}"
  | _ -> "other"
}

Recursive functions

Top-level definitions in a module all share one scope (see Declaration order), so a top-level function can call itself without any extra syntax:

def factorial: int -> int = { n ->
  if n <= 1 then 1
  else n * factorial(n - 1)
}

def length: list(a) -> int = {
  | [] -> 0
  | [_, ...tail] -> 1 + length(tail)
}

A definition inside a function body is different: it is only in scope after it has been defined, so its own body cannot see it. Use the rec keyword to make a local definition visible to itself:

def countdown = { n ->
  rec loop = { i ->
    if i > 0 then
      println("{i}")
      loop(i - 1)
  }

  loop(n)
}

Tail recursion

Tail recursion is a recursive style where the recursive call is the last operation in the function. This is much more efficient because the compiler can reuse the same stack frame for each call, effectively turning the recursive function into a loop and preventing stack overflow on large inputs.

A common pattern is to nest a helper function that carries an accumulator:

def sum: list(int) -> int = { xs ->
  rec loop = {
    | [], acc -> acc
    | [head, ...tail], acc -> loop(tail, acc + head)
  }

  loop(xs, 0)
}

Here loop is internal to sum, and acc stores the running result. Each recursive step passes the updated accumulator forward, and the base case returns it directly.

Function composition

Functions can be composed to create new functions:

def compose: ((b -> c), (a -> b)) -> (a -> c) = { f, g ->
  { x -> f(g(x)) }
}

def addOne = { + 1 }
def double = { * 2 }

def addOneThenDouble = compose(double, addOne)
def result = addOneThenDouble(5)  -- 12

Currying

While Nanyx functions naturally take one argument, you can create curried-style functions:

def add: int -> (int -> int) = { x ->
  { y -> x + y }
}

def add5 = add(5)
def result = add5(10)  -- 15

Partial application

With records, you can simulate partial application:

def process: (config: Config, data: Data) -> Result = { config, data ->
  -- processing logic
}

-- Create a partially applied version
def processWithConfig = { data -> process(myConfig, data) }

Type Annotations for clarity

While type inference works well, annotating function signatures is recommended for exported functions:

-- Without annotation (inferred)
def add = { x, y -> x + y }

-- With annotation (clearer, better errors)
export def add: (int, int) -> int = { x, y -> x + y }

Type annotations serve as documentation and help catch errors early.

Pure functions

Functions without effects (no contexts) are pure - they always return the same output for the same input:

-- Pure function
def add: (int, int) -> int = { x, y -> x + y }

-- Effectful function (requires context)
def greet: [Console] string -> () = { name ->
  println("Hello, {name}!")
}

Function examples

Map Implementation

def map: (list(a), (a -> b)) -> list(b) = { xs, f ->
  match xs
    | [] -> []
    | [head, ...tail] -> [f(head), ...map(tail, f)]
}

Filter implementation

def filter: (list(a), (a -> bool)) -> list(a) = { xs, predicate ->
  match xs
    | [] -> []
    | [head, ...tail] ->
        if predicate(head) then [head, ...filter(tail, predicate)]
        else filter(tail, predicate)
}

Fold implementation

def fold: (list(a), b, ((b, a) -> b)) -> b = { xs, acc, f ->
  match xs
    | [] -> acc
    | [head, ...tail] -> fold(tail, f(acc, head), f)
}

Polymorphic functions

In Nanyx a polymorphic function is simply one whose return type depends on the types of its argument(s)

The simplest polymorphic function is probably the identity function, i.e. a function that does nothing other than return its input

def id = { x -> x }

Thanks to type inference we don’t have to write type annotations for this function or give it a specification, but if we wanted to it would look like this:

def id: a -> a = { x -> x }

The lowercase a, when used in a type position, is a type variable (equivalent to the concept of ‘generics’ in other languages).

def head: list(a) -> #some(a) | #emptyList = {
  | []       -> #emptyList
  | [x, ...] -> #some(x)
}