Skip to content

Functions

Defining a Function

Use def to define a named function. The last expression is implicitly returned — return is optional at the end.

def add(a, b)
  a + b
end

puts add(3, 4)   # 7

return is still useful for early exits:

def divide(a, b)
  return nil if b == 0
  a / b
end

puts divide(10, 2)   # 5.0
puts divide(10, 0)   # nil

Default Parameters

Parameters can have default values. Required parameters must come before optional ones.

def greet(name, msg = "Hello", punct = "!")
  puts "#{msg}, #{name}#{punct}"
end

greet("Alice")              # Hello, Alice!
greet("Bob", "Hi")          # Hi, Bob!
greet("Carol", "Hey", ".")  # Hey, Carol.

Gradual Type Annotations (v1.19)

Annotate parameters and return values with name: Type and -> Type. Annotations are entirely optional and cost nothing at runtime — they're checked statically, by frankiec check, in CI, and live in your editor via the LSP.

def area(r: Float) -> Float
  3.14159 * r * r
end

def clamp(x: Int, lo: Int, hi: Int) -> Int
  return lo if x < lo
  return hi if x > hi
  x
end

puts area(3)        # fine — Int flows into Float
puts area("five")   # ✗ area() argument 1 expects Float, got String

Un-annotated functions and un-annotated parameters within a partially-annotated function are completely untouched — add types only where they're useful.

Type names

Canonical Aliases
Int Integer
Float Number
String Str
Bool Boolean
Vector
Hash
Lambda
Range
Nil
Any matches anything

Return annotations are checked against every return and the implicit final expression. Simple local inference follows literals, arithmetic, and calls to other annotated functions.

Coexists with keyword defaults

A bare reserved type name in parameter position is an annotation; anything else is still a keyword default — no existing code changes meaning:

def connect(host: String, port: 5432, ssl: true)
  # host is annotated String; port and ssl are keyword defaults
end

Where to catch violations: frankiec check file.fk (or a whole project since v1.18: frankiec check .), CI via --strict, or live as you type via frankiec lsp.


Named Arguments

Some built-in functions accept named arguments with key: value syntax:

puts paste("2025", "03", "14", sep: "-")   # 2025-03-14
puts sprintf("%-10s %d", "Frankie", 1)

Multiple Return Values

Return a vector and destructure it at the call site:

def min_max(v)
  [min(v), max(v)]
end

lo, hi = min_max([3, 1, 4, 1, 5, 9])
puts "#{lo}..#{hi}"   # 1..9

def stats(v)
  [mean(v), median(v), stdev(v)]
end

avg, med, sd = stats([10, 20, 30, 40, 50])
puts avg   # 30.0
puts med   # 30

Recursion

Functions can call themselves. Frankie supports full recursion.

def factorial(n)
  return 1 if n <= 1
  n * factorial(n - 1)
end

puts factorial(10)   # 3628800

def fib(n)
  return n if n <= 1
  fib(n - 1) + fib(n - 2)
end

puts fib(10)   # 55

Lambdas — Functions as Values

Use -> to create an anonymous function that can be stored, passed, and called later.

double = ->(x) { x * 2 }
puts double.call(5)   # 10

add = ->(a, b) { a + b }
puts add.call(3, 4)   # 7

Multi-statement bodies use do...end:

clamp = ->(n, lo, hi) do
  return lo if n < lo
  return hi if n > hi
  n
end

puts clamp.call(150, 0, 100)   # 100
puts clamp.call(-5, 0, 100)    # 0
puts clamp.call(42, 0, 100)    # 42

Lambdas accept default parameters:

greet = ->(name, prefix = "Hello") { "#{prefix}, #{name}!" }
puts greet.call("Alice")         # Hello, Alice!
puts greet.call("Bob", "Hi")     # Hi, Bob!

Lambdas as Arguments

Pass lambdas to functions for higher-order patterns:

def apply(fn, val)
  fn.call(val)
end

square = ->(x) { x * x }
puts apply(square, 6)   # 36

# Store in a vector and call each
transforms = [
  ->(x) { x + 1 },
  ->(x) { x * 2 },
  ->(x) { x ** 2 }
]

transforms.each do |fn|
  puts fn.call(5)
end
# 6
# 10
# 25

Blocks

Blocks are anonymous chunks of code passed directly to a method call with do...end or { }. They are not stored — they run once as part of the method.

[1, 2, 3].each do |x|
  puts x * 2
end

result = [1, 2, 3].map do |x|
  x * x
end
puts result   # [1, 4, 9]

Single-line blocks can use braces:

[1, 2, 3].each do |x| puts x end

Blocks are the foundation of Frankie's iterator system — .map, .select, .reduce, .each_with_object, and all other iterators take blocks.


The Pipe Operator

Chain function calls without nesting:

data = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3]

data |> sum |> puts    # 39
data |> mean |> puts   # 3.9

def double(x)
  x * 2
end

10 |> double |> puts   # 20

Scope

Variables defined outside a function are readable inside it. Variables assigned inside a function are local to it — they do not affect the outer scope.

x = 10

def read_outer
  puts x   # 10 — outer x is readable
end
read_outer

def set_local
  x = 99   # local to set_local
  puts x   # 99
end
set_local
puts x     # 10 — outer x unchanged

Multi-file Programs

Split code across files with require. Each file is loaded at most once.

# lib/math_utils.fk
def circle_area(r)
  PI * r * r
end

# main.fk
require "lib/math_utils"

puts circle_area(5)   # 78.539...

require resolves paths relative to the current working directory. The .fk extension is optional.