Sake-lang

the Sake language, pronounced /seɪk/

Sake is designed for the sake of finding a new place for types. It keeps Ruby's syntax, but you write the type on each operation, never on a variable. Each line then says exactly which function runs, and every name is checked before the program starts.

Ruby
name.upcase
3.times { p it }
pt.x
Sake
String.upcase(name)
Integer.times(3) { p it }
Point.x(pt)
  • Parsed by Prism
  • No types on variables, parameters, or fields
  • All names checked before running
  • Every operation checks its arguments

The language

The idea

Most languages put types where a value is bound (variables, parameters, fields), and each use has to look back at that declaration. Sake puts the type where the value is used.

Types on bindingsTypes on operations (Sake)
Examplex: String; x.upcaseString.upcase(x)
Meaning of a lineNeeds the declarationFixed by the line itself
A misspelled operationFound once the receiver's type is knownFound by name, before running
CostThe type is written onceThe type is written at every use; the uses check each other

The examples on this page are real. Each one is a file in docs/examples/, and the dark panel under it is that file's actual output.

Running a program

puts("Hello, Sake!")
puts(String.upcase("operations carry their type"))
$ sake hello.sake
Hello, Sake!
OPERATIONS CARRY THEIR TYPE
CommandWhat it does
bin/sake FILE.sakeChecks the whole program, then runs it
bin/sake -c FILE.sakeOnly checks; does not run
bin/sake --strict FILE.sakeChecks more before running (level 2, below)
bin/sake --types FILE.sakeExperimental: prints the inferred types without running

How strict

LevelHow to askAlso stops before running
0--strict=0only syntax, names, argument counts, calls on values, and the like
1(the default)type: a value whose type (other than nil) does not fit the operation; rescue: a rescue clause an earlier clause already covers
2--strictnil: a value that may be nil, used without a check; mixed: a type report where values of different types met in a field (a warning at level 1)
3--strict=3index-nil: the result of x[k], used without a check; exhaustive: a case whose literal branches may miss some value
4--strict=4unrescued: a raise that may reach the top level

Items can also be named: --strict=type,nil, or --strict=3,-index-nil. One item is in no level: unused-rescue, a rescue of an exception never raised, is a warning unless named. Whatever is not checked before running is still checked while running, by each operation.

A program can span several files and use Sake's library (Files, libraries, and arguments). To try Sake in a browser, with completion and the inferred types on hover, open the playground at ko1.github.io/sake-lang/playground (ide/ builds it).

The exit status is 0 on success, 1 for an error while running, and 2 for an error found before running (nothing ran).

Operations carry their type

Type.op(subject, args…) calls the operation op of Type. The subject comes first. A call on a value, such as name.upcase, is rejected, and the error shows how to write it with the type. Chains are rewritten as a whole.

name = "sake"
puts(name.upcase)
puts(name.strip.capitalize)
$ sake method_call.sake
method_call.sake:2:11: error: method call on a value `name.upcase` is not allowed
  hint: String.upcase(name)
  hint: Symbol.upcase(name)
method_call.sake:3:17: error: method call on a value `name.strip.capitalize` is not allowed
  hint: String.strip(name).capitalize
  hint: String.strip(name).Symbol.capitalize
(exit status 2)

Every type and operation name is checked before the program starts, so a typo never fails halfway through a run:

puts(String.upcse("sake"))
puts(Strng.reverse("sake"))
puts(Integer.to_string(42))
$ sake typo.sake
typo.sake:1:13: error: undefined function `String.upcse`
  hint: did you mean `String.upcase`?
typo.sake:2:6: error: undefined type or module `Strng`
  hint: did you mean `String`?
typo.sake:3:14: error: undefined function `Integer.to_string`
  hint: did you mean `Integer.to_int`?
(exit status 2)

Nested calls read inside out. Two forms let them read in order, with every step still naming its type: x.T.f(…) is T.f(x, …), and _ is the value of the previous statement.

line = "pear, Apple ,fig"

# Left to right: the value on a step's left is its first argument. A step names its type where the
# type changes (String.split gives an Array), and the others continue with the type named last.
puts(String.split(line, ",")
  .Array.map { |s| String.strip(s).downcase }
  .sort
  .join(" < "))

# Top to bottom: `_` is the previous statement's value.
String.split(line, ",")
Array.map(_) { |s| String.strip(s).size }
puts("longest: #{Array.max(_)}")
$ sake chains.sake
apple < fig < pear
longest: 5

Rules

  • Built-in names follow Ruby's core library: Integer.to_s, String.include?, Array.each. Names from other languages, such as Integer.to_string, do not exist.
  • Calls on values are always rejected, including on literals such as 3.times and "a".upcase. For a literal, the hint names its type.
  • p.x suggests Point.x(p), p.x = v suggests p.Point.x = v, and x.nil? suggests x == nil.

Numbers and operators

Operators behave as in Ruby: a + b runs the + of a's type. For the built-in types, a fixed table gives the result for each pair of operand types.

puts(7 / 2)           # Integer / Integer truncates toward -infinity, as in Ruby
puts(-7 / 2)
puts(7 % 3)
puts(7 / 2.0)         # mixing Integer and Float gives Float
puts(2 ** 10)
puts(Float.round(3.14159, 2))
puts(Float.round(2.5))   # without digits: an Integer
puts(Integer.+(1, 2))    # the operator written with its type
puts("ab" * 3)
puts("a" < "b")
p(1/3r + 1/6r)
p(2r ** -1)
$ sake numbers.sake
3
-4
1
3.5
1024
3.14
3
3
ababab
true
(1/2)
(1/2)

The table for +

left \ rightIntegerFloatStringnil
IntegerIntegerFloat——
FloatFloatFloat——
String——String—
nil————

A pair marked — is reported before running. There is no implicit conversion. Every operation also checks its arguments while running; with --strict=0, the same mistake stops when it runs:

total = 10
label = "items: "
puts(label + total)
$ sake binop_error.sake
binop_error.sake:3:6: error: Arithmetic.+: the operands are (String, Integer), which the left operand's type does not support [type]
(exit status 2)
$ sake --strict=0 binop_error.sake
binop_error.sake:3: in <main>: TypeError: Arithmetic.+: no implementation for (String, Integer); defined for (any two of Integer, Float, Rational, Complex), (String, String), (Array, Array), (Time, Integer), (Time, Float), (Time, Rational)
(exit status 1)

Writing the operator with a type, as in Integer.+(a, b), runs Integer's + directly: the left operand must be an Integer, and the right one any type that + takes with it:

puts(Integer.+(1, 2.5))        # Integer's +: a Float on the right is fine
puts(Integer.+(2.5, 1))        # but the left operand must be an Integer
$ sake typed_op_error.sake
typed_op_error.sake:2:6: error: Integer.+: argument 1 must be Integer, but is Float [type]
(exit status 2)

== compares any two values: by content for Tuples, Arrays, Hashes, Sets, Records and Structs, and false between different types. Tuples and Arrays are ordered element by element, so a Tuple makes a sort key of several parts. -x runs the -@ of x's type, and !x is x ? false : true:

# == compares by content, and is false between different types (as in Ruby)
p([1, "a"] == [1, "a"])             # Tuples, Arrays, Hashes, Sets and Records: by content
p(1 == "1")
p([2, "b"] < [2, "c"])               # Tuples and Arrays are ordered element by element
scores = Any[["ann", 90], ["bob", 72], ["cy", 90]]
p(Array.sort_by(scores) { |name, s| [-s, name] })   # a Tuple as the sort key: score down, name up
x = 5
p([-x, !(x > 3)])                    # -x runs the -@ of x's type; !x is `x ? false : true`
$ sake comparing.sake
true
false
true
[["ann", 90], ["cy", 90], ["bob", 72]]
[-5, false]

Rules

  • - * / % ** take the same numeric pairs as + (Integer, Float, Rational, Complex). * also takes (String, Integer); Arrays have + - * as in Ruby.
  • < <= > >= compare two numbers, two Strings, two Symbols, or two Tuples or Arrays element by element.
  • == != compare any two values. A class with Comparable and <=> is equal where <=> gives 0.
  • Integer / and % follow Ruby: -7 / 2 == -4. Dividing an Integer by zero raises ZeroDivisionError.
  • Integer ** negative raises ArgumentError; for a Rational, write 2r ** -1.
  • x += 1 means x = x + 1. && and || short-circuit as in Ruby.

Functions

Functions have no type annotations, and do not need them: what a function accepts follows from its operations (String.upcase(s) needs a String), and each call is checked with the types it is given. A function works with any type its operations accept, so square below works on both Integer and Float.

def square(x) = x * x

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

def min_max(a, b)
  return a, b if a < b
  [b, a]
end

puts(square(12))
puts(square(1.5))           # functions without types are polymorphic
puts(fib(20))
lo, hi = min_max(9, 4)      # a Tuple is destructured by multiple assignment
puts(lo)
puts(hi)
p(min_max(1, 2))
$ sake functions.sake
144
2.25
6765
4
9
[1, 2]

Optional and keyword parameters

As in Ruby, required parameters come first, then optional ones with a default, then keyword parameters, required (w:) or with a default. A default is evaluated at the call and may read the earlier parameters.

# Optional parameters, then keyword parameters, as in Ruby.
def greet(name, greeting = "Hello", punct: "!", times: 1)
  String.*("#{greeting}, #{name}#{punct} ", times)
end

def area(w:, h: w) = w * h    # w: is required; h's default reads w

puts(greet("Ruby"))
puts(greet("Ruby", "Hi"))
puts(greet("Ruby", times: 2))
puts(greet("Sake", "Yo", punct: "?"))
p(area(w: 3))
p(area(h: 2, w: 5))
times = 3
punct = "."
puts(greet("you", times:, punct:))     # `k:` alone passes the variable k
$ sake params.sake
Hello, Ruby! 
Hi, Ruby! 
Hello, Ruby! Hello, Ruby! 
Yo, Sake? 
9
10
Hello, you. Hello, you. Hello, you. 

Every call's function is known before running, so keyword arguments are matched to their parameters by name then. A misspelled or missing keyword, or one given to a function that has none, is an error before running. There is no implicit Hash argument as in Ruby: write Hash.of(k: v) or a Record {k: v} for that.

def area(w:, h: w) = w * h
def total(xs, init = 0) = Array.sum(xs, init)
p(area(width: 3))        # no such keyword, and w: is missing
p(total(Any[1], 0, 1)) # too many arguments
p(total(Any[1], start: 0))
$ sake params_errors.sake
params_errors.sake:3:3: error: area needs keyword argument `w:`
params_errors.sake:3:8: error: area has no keyword parameter `width`
params_errors.sake:4:3: error: wrong number of arguments for total (given 3, expected 1..2)
params_errors.sake:5:17: error: total takes no keyword arguments
(exit status 2)

Rules

  • The forms are def f(a, b) … end and, for a short expression, def f(x) = expr.
  • Parameters: required, then optional (b = 1), then keywords (c:, d: 2), then a block parameter &b, which only passes the block on (Blocks). *rest collects the remaining positional arguments in an Array and **opts the other keywords in a Hash.
  • Arguments are evaluated in the order written. f(k:) passes the variable k.
  • first, *rest = xs and |a, *rest| collect the rest in an Array. *xs in arguments spreads only into built-ins that take any number of them: puts(*lines), format(fmt, *row), Any[*xs, 0].
  • return a, b and the literal [a, b] both make a Tuple. x, y = t takes it apart, and an Array too: missing elements are nil, extra ones dropped, as in Ruby.
  • Definitions are collected before anything runs, so a function may be called above its definition.
  • Recursion is limited to a depth of 10,000. Deeper calls raise SystemStackError.

Control flow

if/elsif/else, unless, while, until, the modifier forms, and c ? a : b all work as in Ruby.

def fizzbuzz(n)
  if n % 15 == 0
    "FizzBuzz"
  elsif n % 3 == 0
    "Fizz"
  elsif n % 5 == 0
    "Buzz"
  else
    Integer.to_s(n)
  end
end

i = 1
while i <= 15
  print(fizzbuzz(i), " ")
  i += 1
end
puts

n = 27
steps = 0
until n == 1
  n = Integer.even?(n) ? n / 2 : 3 * n + 1
  steps += 1
end
puts("collatz(27) steps: #{steps}")
puts("small") unless steps > 100
puts("ok") if steps == 111
$ sake control.sake
1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzz 
collatz(27) steps: 111
ok

Matching by type

x in Integer tests a value's type. case x with in branches picks the branch by type, literal, or Record shape. Inside a matching branch, the variable has the matching type, so a value that may be an Integer or a String can be used without a type report. There is no ===, so case/when is not supported.

def describe(x)
  case x
  in Integer | Float then "number #{x}"
  in String then "string #{x}"
  in nil then "nothing"
  in {name:} then "record named #{name}"
  in :ok then "ok symbol"
  else "other"
  end
end
Point = Struct.new(:x, :y)
p(describe(3)); p(describe(2.5)); p(describe("s")); p(describe(nil)); p(describe({name: "r"})); p(describe(:ok)); p(describe(Point.new(1, 2)))

def pick(c) = c ? 1 : ""
v = pick(true)
if v in Integer
  p(v + 1)
else
  p(String.size(v))
end
p((Point.new(1, 2) in Point))
p(("s" in Integer | Float))
$ sake patterns.sake
"number 3"
"number 2.5"
"string s"
"nothing"
"record named r"
"ok symbol"
"other"
2
true
false

x => Integer asserts a type, as in Ruby: after it, x is an Integer, and a value that does not match raises NoMatchingPatternError. Types are written on operations, never on variables or fields, so this is how a fact such as "a port is an Integer" is stated where the value is stored. It is also a check for nil.

# `x => T` asserts a type: after it, x is a T; a value that does not match raises
# NoMatchingPatternError, and is reported before running when the checker sees it.
class Config
  attr_reader host, port
  def initialize(c) = @port = 80
  def set_port(c, p)
    p => Integer              # the knowledge "a port is an Integer", on the write
    @port = p
  end
end
c = Config.new("example.com")
Config.set_port(c, 8080)
p(c)
v = Array.first(Any[3, 4])  # Integer or nil
v => Integer                  # also a check for nil: v + 1 needs none
p(v + 1)
Config.set_port(c, "eighty")
$ sake pattern_assert.sake
pattern_assert.sake:7:5: error: `=> Integer`: the value is String, which does not match [type]
  hint: reached by the call at line 17
(exit status 2)

The set of types is closed, so a case that may match nothing is found before running:

def only_ints(x)
  case x
  in Integer then x * 2
  end
end
p(only_ints(4))
p(only_ints("a"))
$ sake patterns_exhaustive.sake
patterns_exhaustive.sake:2:3: error: case/in: no `in` branch matches String [type]
  hint: add an `in` branch for it, or an `else`
  hint: reached by the call at line 7
(exit status 2)

Rules

  • Only nil and false are falsy. 0 and "" are truthy.
  • An if without an else, and every loop, gives nil.
  • break and next work inside while and until.

Blocks

An operation that takes a block is called like any other, with the subject first. A block's single parameter can be named it or _1.

nums = Any[3, 1, 4, 1, 5, 9, 2, 6]

Array.each(Any[1, 2, 3]) { |x| print(x * 10, " ") }
puts
p(Array.map(nums) { |x| x * x })
p(Array.select(nums) { it > 3 })                   # `it` names the only parameter
p(Array.sort_by(nums) { -_1 })                     # so does _1
puts(Array.reduce(nums, 0) { |acc, x| acc + x })
Array.each_with_index(Any["a", "b"]) { |s, i| puts("#{s}#{i}") }
Integer.times(3) { |i| print(i, " ") }
puts
$ sake blocks.sake
10 20 30 
[9, 1, 16, 1, 25, 81, 4, 36]
[4, 5, 9, 6]
[9, 6, 5, 4, 3, 2, 1, 1]
31
a0
b1
0 1 2 

Your own functions receive a block through yield. If a block has several parameters and receives one Tuple, the Tuple is taken apart. return inside a block leaves the enclosing function.

def twice(x) = yield(yield(x))

def each_pair(a)
  Array.each(a) { |x| yield([x, x * x]) }   # yield a Tuple...
  a
end

def first_over(a, limit)
  Array.each(a) { |x| return x if x > limit }   # return leaves first_over
  0
end

puts(twice(3) { |x| x + 4 })
each_pair(Any[1, 2, 3]) { |x, sq| puts("#{x} -> #{sq}") }   # ...destructured here
puts(first_over(Any[1, 5, 10], 4))
odd_only = Array.map(Any[1, 2, 3, 4]) { |x| next 0 if Integer.even?(x); x }
p(odd_only)
$ sake yield.sake
11
1 -> 1
2 -> 4
3 -> 9
5
[1, 0, 3, 0]

Blocks are not values, so the interpreter knows which block each yield runs and checks block use before running:

def twice(x) = yield(yield(x))
def add(a, b) = a + b

puts(twice(1))
puts(add(1, 2) { |x| x })
Array.each(Any[1, 2])
$ sake block_errors.sake
block_errors.sake:4:6: error: twice uses `yield` but no block is given
  hint: a function that checks `block_given?` may be called without a block
block_errors.sake:5:6: error: add does not take a block (it has no `yield`)
block_errors.sake:6:7: error: Array.each requires a block
(exit status 2)

break ends the call the block was given to, which then gives the break's value. A parameter can be taken apart, |(name, n), i|, and |head, *rest| collects the rest:

nums = Any[3, 8, 12, 5]
found = Array.each(nums) { |n| break n if n > 10 }    # break ends the call; its value is the call's
p(found)
pairs = Any[["a", 1], ["b", 2]]
Array.each_with_index(pairs) { |(name, n), i| puts("#{i}: #{name}=#{n}") }  # a parameter taken apart
Array.each(Any[[1, 2, 3], [4, 5, 6]]) { |head, *rest| p([head, rest]) }  # the rest, as an Array
$ sake blocks_more.sake
12
0: a=1
1: b=2
[1, [2, 3]]
[4, [5, 6]]

Optional blocks, and passing a block on

A function that checks block_given? may be called with or without a block. The checker knows for each call whether a block was given, so it follows only the branch taken. &b passes the function's own block on to another call; b is never a value.

# block_given?: a function that may be called with or without a block.
def info(msg = nil)
  msg = yield if block_given?
  puts("INFO #{msg}")
end
info("plain")
info { "built only when logged: #{Array.sum(Any[1, 2, 3])}" }

def total(xs)
  return Array.sum(xs) unless block_given?
  Array.map(xs) { |x| yield x }.sum
end
p(total(Any[1, 2, 3]))
p(total(Any[1, 2, 3]) { |x| x * x })

# &b passes the function's own block on to another call.
def each_twice(xs, &b)
  Array.each(xs, &b)
  Array.each(xs, &b)
  nil
end
each_twice(Any["a", "b"]) { |s| print(s) }
puts
$ sake block_optional.sake
INFO plain
INFO built only when logged: 6
6
14
abab

Rules

  • A user function takes a block if and only if its body contains yield (or it takes &b). One that checks block_given? may also be called without a block; the checker then follows only the branch taken. A built-in operation either requires a block or rejects one.
  • def f(xs, &b) = Array.map(xs, &b) passes the function's block on. b can only be used as &b. Such a function may be called without a block, as in Ruby; passing none on to a call that needs one is an error.
  • A yield reached without a block is an error before running (level 1), and LocalJumpError when it runs.
  • A block with no parameters ignores its arguments. Otherwise, the number of arguments must match. A block with several parameters takes one Tuple or Array apart.
  • next v gives the block's value; break v the value of the call it was given to.
  • A block can read and assign the variables around it.
  • A block cannot be stored or returned: proc, lambda, and -> are not available.

Data

Types and instances

A class declares a type with fields; its values are instances. The fields are declared in the body with attr_reader, attr_accessor, and attr_writer, as in Ruby but with the names written bare. The type's operations are its own functions, called with the type's name, the instance first.

class Point               # a type with two fields, read-only from outside
  attr_reader x, y
  def add(a, b) = Point.new(@x + Point.x(b), @y + Point.y(b))
  def norm2(p) = @x * @x + @y * @y           # @x: field x of the first argument, Point.x(p)
  def to_s(p) = "(#{@x}, #{@y})"             # used by puts and "#{...}"
end

class Counter
  attr_reader name
  attr_accessor count
  def initialize(c) = @count = 0
  def bump(c) = @count += 1                  # inside the class, @count += 1 writes the field
end

a = Point.new(1, 2)                          # an instance: one argument per field
b = Point.add(a, Point.new(3, 4))
puts(b)
p(b)                                         # p shows the fields
p([Point.norm2(b), Point.x(b)])
p(a == Point.new(1, 2))                      # == compares the fields

c = Counter.new("hits")                      # count takes its default
d = c                                        # the same instance: values are shared, not copied
Counter.bump(d)
Counter.set_count(c, Counter.count(c) + 10)   # accessor: writable from outside too
p(c)
$ sake instances.sake
(4, 6)
#<struct Point x=4, y=6>
[52, 4]
true
#<struct Counter name="hits", count=11>

Declaring a type

In the bodyFields it declares
attr_reader x, yC.x(c) from anywhere; written only inside class C (with @x = v)
attr_accessor nC.n(c) and C.set_n(c, v) from anywhere
attr_writer wC.set_w(c, v) from anywhere; read only inside the class
def initialize(c)runs after new stored the fields: set the others there (@items = Any[]); with it, new may leave out trailing fields, which are nil until initialize sets them. Fields have no default values
private attr_reader posno C.pos outside the class (inside: @pos, or C.pos(other) for another C); new still takes it
class E < Exceptionan exception type: message comes first

Use attr_reader for every field that is not changed from outside, which is most of them: every write to a field is then in its own class, where the checker finds its type. C = Struct.new(:x, :y) is shorthand for a class C with attr_accessor x, y, and E = Exception.new(:line) for an exception type.

initialize

Types are never written on fields, so a fact such as "a port is an Integer" goes where every value passes: def initialize(c) runs after C.new has stored the fields, as in Ruby. It checks (@port => Integer) or converts (@celsius = Float(@celsius)). The checker runs it for each new call with that call's values, so a wrong argument is reported at the call.

# initialize(c) runs after T.new has stored the fields: checks and conversions on every construction.
class Config
  attr_reader host, port
  def initialize(c)
    @port = 80 if @port == nil
    @port => Integer
    @host = String.downcase(@host)
  end
  def next_port(c) = @port + 1
end
p(Config.new("EXAMPLE.com"))
p(Config.new("h", 8080).next_port)

class Temp
  attr_reader celsius
  def initialize(t) = @celsius = Float(@celsius)    # a conversion: the field is always a Float
end
p(Temp.new(20))
p(Temp.new("21.5"))
Config.new("h", "eighty")      # reported for this call
$ sake initialize.sake
initialize.sake:6:5: error: `=> Integer`: the value is String, which does not match [type]
  hint: reached by the call at line 20
(exit status 2)

Writing one class's definitions into another

class B < A is shorthand for writing A's definitions in B: A's fields come first, then B's own; A's functions become B's, with unqualified names and @x inside them meaning B's; B's own definition of a function wins. It is not inheritance: afterwards A and B are unrelated, and a B is not an A.

# B < A: A's definitions written again in B. Point3 is not a Point.
class Point
  attr_reader x, y
  def sum(pt) = @x + @y
  def describe(pt) = "#{name(pt)}(#{@x}, #{@y}) sum=#{sum(pt)}"
  def name(pt) = "Point"
end
class Point3 < Point
  attr_accessor z
  def initialize(p)
    @z = 0 if @z == nil
  end
  def sum(pt) = @x + @y + @z
  def name(pt) = "Point3"
end
class ParseError < Exception
  attr_reader line
end
puts(Point.describe(Point.new(1, 2)))
puts(Point3.describe(Point3.new(1, 2, 3)))
p(Point3.new(4, 5))
begin
  raise ParseError.new("bad", 3)
rescue ParseError => e
  p([Exception.message(e), e.ParseError.line])
end
$ sake class_paste.sake
Point(1, 2) sum=3
Point3(1, 2) sum=6
#<struct Point3 x=4, y=5, z=0>
["bad", 3]

Instances

  • C.new(a, b) takes one positional argument per field, in the order the fields are declared.
  • Fields are mutable and instances are shared, not copied: after d = c, a change through d is seen through c. (Ruby's Data is immutable, which is why Sake's types follow Ruby's Struct.)
  • == compares the fields, unless the type defines == (or Comparable with <=>). Instances can be Hash keys and Set elements, except for a type with its own equality.
  • p shows #<struct Point x=4, y=6>, as Ruby does. A type's own to_s(c) is used by puts, "#{…}", Array.join, and %s; its own inspect(c) by p. Each returns a String.
  • x in Point and case x in Point test an instance's type.

Functions of a type

Functions written inside class C … end are C's operations: C.f(c, …). By convention the instance comes first. Inside them:

  • @x, @x = v, and @x += v read and write field x of the function's first argument. The type comes from the enclosing class, so @x is still an operation with a type (C.x(c)). There is no self.
  • Unqualified names, such as x (the reader) or another function of C, refer to C's operations first. A function named like a field replaces its reader, as in Ruby.
  • def C.f(c) outside the class is shorthand for def f(c) inside it. A class can be opened again to add functions.

The same with Struct.new and the reader C.x(c) / writer C.set_x(c, v) spelled out:

Point = Struct.new(:x, :y)

class Point
  def add(a, b) = Point.new(x(a) + x(b), y(a) + y(b))
  def to_s(p) = "(#{x(p)}, #{y(p)})"       # used by puts and "#{...}"
end

def Point.scale!(p, k)           # same as defining `scale!` inside `class Point`
  set_x(p, x(p) * k)
  set_y(p, y(p) * k)
  p
end

a = Point.new(1, 2)
b = a                             # b and a are the same value
Point.scale!(b, 10)
puts(a)                           # uses Point.to_s; the change through b is visible through a
puts("sum: #{Point.add(a, Point.new(5, 5))}")
p(a)
puts(Point.x(a))
$ sake data.sake
(10, 20)
sum: (15, 25)
#<struct Point x=10, y=20>
10

@x refers to the first argument, whatever it is; passing something else is reported:

Point = Struct.new(:x, :y)

class Point
  def norm2(p) = @x * @x + @y * @y           # @x is Point.x(p): p is the first argument
  def move(p, dx, dy)
    @x += dx                                 # Point.set_x(p, Point.x(p) + dx)
    @y += dy
    p
  end
end

pt = Point.new(3, 4)
puts(Point.norm2(pt))
Point.move(pt, 1, 1)
p(pt)
puts(Point.norm2(7))
$ sake data_shorthand.sake
data_shorthand.sake:4:18: error: Point.x: argument 1 must be Point, but is Integer [type]
  hint: reached by the call at line 16
data_shorthand.sake:4:23: error: Point.x: argument 1 must be Point, but is Integer [type]
  hint: reached by the call at line 16
data_shorthand.sake:4:28: error: Point.y: argument 1 must be Point, but is Integer [type]
  hint: reached by the call at line 16
data_shorthand.sake:4:33: error: Point.y: argument 1 must be Point, but is Integer [type]
  hint: reached by the call at line 16
(exit status 2)

Mistakes

There is no p.x. A read-only field is not written from outside, a field that does not exist is not read, and new takes every field:

class Point
  attr_reader x, y
end
pt = Point.new(1, 2)
puts(pt.x)
Point.set_x(pt, 5)
puts(Point.z(pt))
puts(Point.new(1))
$ sake instance_errors.sake
instance_errors.sake:5:9: error: method call on a value `pt.x` is not allowed
  hint: Point.x(pt)
instance_errors.sake:6:7: error: field `x` of Point is read-only (attr_reader)
  hint: inside `class Point`, use `@x = value`; or declare it with `attr_accessor x`
instance_errors.sake:7:12: error: undefined function `Point.z`
instance_errors.sake:8:12: error: wrong number of arguments for Point.new (given 1, expected 2)
(exit status 2)

Passing the wrong instance is reported before running, with a hint pointing back to the call. While running, the same check stops at the exact operation and prints the call chain:

Point = Struct.new(:x, :y)
Line = Struct.new(:from, :to)

def length(l)
  dx = Point.x(Line.to(l)) - Point.x(Line.from(l))
  dy = Point.y(Line.to(l)) - Point.y(Line.from(l))
  Math.sqrt(dx * dx + dy * dy)
end

puts(length(Line.new(Point.new(0, 0), Point.new(3, 4))))
puts(length(Point.new(3, 4)))
$ sake data_runtime_error.sake
data_runtime_error.sake:5:16: error: Line.to: argument 1 must be Line, but is Point@L11 [type]
  hint: reached by the call at line 11
(exit status 2)
$ sake --strict=0 data_runtime_error.sake
5.0
data_runtime_error.sake:5: in length: TypeError: Line.to: argument 1 must be Line, got Point
  from data_runtime_error.sake:11: in <main>
(exit status 1)

Modules

A module is a namespace of functions with no type and no instances. It is used in two ways: as a home for plain functions, and as a mixin that types include.

class Cmodule M
Is a typeYes: C.new, fields, x in CNo
FunctionsC's operations, instance firstModule functions, or mixin functions for the types that include M
Called asC.f(c)M.f(x): directly (module_function), or dispatched to x's type
Combined byinclude M; class C < A writes A's definitions in Cinclude of other modules
module Geometry                              # a module: functions only, no instances
  module_function
  def dist2(ax, ay, bx, by) = (ax - bx) ** 2 + (ay - by) ** 2
end

module Shape                                 # a mixin: what includers share, and what they must define
  def area(s) = raise(NotImplementedError)
  def describe(s) = "area #{area(s)}"        # area: the includer's own
end

class Square              # a class: a type, with instances
  attr_reader side
  include Shape
  def area(q) = @side * @side
end

class Disc
  attr_reader r
  include Shape
  def area(d) = 3 * @r * @r
end

p(Geometry.dist2(0, 0, 3, 4))
puts(Square.describe(Square.new(2)))         # Square's describe, borrowed from Shape
puts(Shape.describe(Disc.new(1)))            # dispatch: the describe of the argument's type
$ sake class_module.sake
25
area 4
area 3

Module functions

Functions after module_function are called directly: Geometry.dist2(…), Units.km(x). Such a module is a namespace, like Ruby's Math.

Mixins and dispatch

include M copies M's functions into a class, as Ruby's modules do, but statically. Inside a copied function, unqualified names are looked up in the class that includes it, so Shape.describe uses the area of Square or of Disc. Calling a mixin function through its module, as Shape.describe(x), dispatches: it runs the function of x's type, which must include the module. This is the one place besides operators where the function is picked while running, and the module name on the call says so.

module Summary                       # mixin functions: they need `each` and `count` from the includer
  def total(c)
    sum = 0
    each(c) { |x| sum += x }
    sum
  end
  def average(c) = total(c) / count(c)
end

class Basket     # `class` adds operations to a type
  attr_reader items
  include Summary
  def each(b) = Array.each(@items) { |x| yield(x) }
  def count(b) = Array.size(@items)
end

class Countdown
  attr_reader n
  include Summary
  def each(c) = Integer.downto(@n, 1) { |i| yield(i) }
  def count(c) = @n
end

module Units                         # `module_function`: called directly, as Units.km
  module_function
  def km(m) = m / 1000.0
end

b = Basket.new(Any[3, 4, 5])
puts(Basket.total(b))                # static: Basket's total (borrowed from Summary)
puts(Summary.total(Countdown.new(4)))  # dispatch: the total of the argument's type
puts(Units.km(2500))
$ sake modules.sake
12
10
2.5

A module states what each includer must define with a function whose body is only raise NotImplementedError. A missing definition and a direct call to a function that needs its includer are reported before running:

module Shape                             # a body of only `raise NotImplementedError`: each includer defines it
  def area(s) = raise(NotImplementedError)
  def describe(s) = "area #{Shape.area(s)}"
end
Square = Struct.new(:side)
class Square
  include Shape
  def area(q) = @side * @side
end
Circle = Struct.new(:r)
class Circle
  include Shape                          # forgot area
end
puts(Shape.describe(Square.new(2)))
puts(Shape.describe(Circle.new(1)))
$ sake required_errors.sake
required_errors.sake:3:29: error: Shape.area: Circle includes Shape but does not define area, which Shape requires (`raise NotImplementedError`) [type]
  hint: define `def area(...)` in class Circle
  hint: reached by the call at line 15
(exit status 2)
module Summary
  def total(c) = each(c) + 1
end
module Empty
  include Summary
end
puts(Summary.total(1))
$ sake module_errors.sake
module_errors.sake:5:3: error: `include Summary` in Empty: Summary.total needs `each`, which Empty does not define (used at line 2)
module_errors.sake:7:14: error: Summary.total is a mixin function, and no type includes Summary
  hint: to call it as Summary.total(...), mark it with `module_function`
(exit status 2)

Operators for your own types

An operator belongs to a module: Arithmetic (+ - * / % **, unary - and +), Comparable (<=> and < <= > >=), Bitwise, and Indexable ([], []=). A type joins by including the module and defining the operator. With Comparable, <=> alone gives the comparisons, ==, and Array.sort.

class Money
  attr_reader cents
  include Arithmetic
  include Comparable
  def +(a, b) = Money.new(@cents + Money.cents(b))
  def <=>(a, b) = @cents <=> Money.cents(b)
  def to_s(m) = "$#{@cents / 100}.#{String.rjust(Integer.to_s(@cents % 100), 2, "0")}"
end
a = Money.new(150)
b = Money.new(275)
puts(a + b)
p(a < b)
p(Array.sort(Any[b, a]))
p(Array.max(Any[a, b]))
p(a == Money.new(150))
p(Array.include?(Any[a], Money.new(150)))
class Grid
  attr_reader cells
  include Indexable
  def [](g, k) = Array.at(@cells, k)
  def []=(g, k, v)
    Array.[]=(@cells, k, v)
  end
end
g = Grid.new(Any[1, 2, 3])
g[1] = 20
g[2] += 1
p(Any[g[0], g[1], g[2]])
$ sake operators.sake
$4.25
true
[#<struct Money cents=150>, #<struct Money cents=275>]
#<struct Money cents=275>
true
true
[1, 20, 4]

Listing the types on the operation

When a value may be one of a few types that have an operation of the same name, list them on the operation: (A|B).f(x) runs the f of x's type. The list is checked before running:

Leaf = Struct.new(:weight)
Node = Struct.new(:weight, :left, :right)

# The operation lists the types its argument may have.
def weight(t) = (Leaf|Node).weight(t)
def size_of(x) = (String|Array).size(x)

puts(weight(Leaf.new(3)) + weight(Node.new(5, Leaf.new(1), Leaf.new(1))))
puts(size_of("sake") + size_of(Any[1, 2]))
puts(size_of(42))
$ sake union_call.sake
union_call.sake:6:18: error: (String|Array).size: argument 1 must be String or Array, but is Integer [type]
  hint: reached by the call at line 10
(exit status 2)

Unqualified names

A call without a type, f(x), is resolved before running. It searches these places in order, and the first match wins:

  1. the enclosing class or module (and what it includes)
  2. top-level functions
  3. Kernel (puts, print, p, format, …)

The inner definition wins, so common names such as open stay usable. To reach an outer one, write its namespace, as in Kernel.puts(…). Only the innermost class or module is searched: inside A::B, a function of A is called as A.f(…).

def label(x) = "top: #{x}"

module Door
  module_function
  def open(d) = label("door")              # Door.label wins over the top-level label
  def label(x) = "door: #{x}"
  def show(d) = puts(open(d))              # Door.open wins over the top-level open
end

def open(x) = label("top")

Door.show(1)
puts(open(1))
puts(Door.open(1))
$ sake scope.sake
door: door
top: top
door: door

Rules

  • The includer's own definition wins over a copied one. Modules included by a module are copied too.
  • include is not inheritance: M.f(x) always runs M's own f or dispatches to x's type. class B < A is not inheritance either: it only writes A's definitions in B.
  • Only modules can be included. Every class is a type, also one without fields; a home for plain functions is a module with module_function.

Tuples, Records, and arrays

A literal has no operation with a type, so its shape fixes its type when it is created. A growable collection is made by an operation with a type.

Written asIsElement types
[1, "a"]TupleFixed length and fixed positional types. Take it apart with x, y = t.
{x: 1, y: "a"}RecordThe set of fields and their types, for example {x: Integer, y: String}. Take it apart with r => {x:, y:}.
Any[1, "a"]ArrayNot declared. Any value can be added.
Float[], Point[p]Array of TT. Every write is checked.
pair = [1, "one"]                 # a literal is a Tuple
n, word = pair
puts(word)
puts(Tuple.size(pair))

xs = Any[1, "two", 3.0]         # Any[...] has no declared element type
Array.push(xs, nil)
p(xs)

ws = String.split("to be or not to be", " ")
p(ws)
puts(Array.map(ws) { String.capitalize(it) }.join(" "))

fs = Float[]                      # T[...] declares the element type
Array.push(fs, 1.5, 2.5)
puts(Array.sum(fs))
Array.push(fs, 3)
$ sake collections.sake
collections.sake:17:1: error: Array.push: an element must be Float, but is Integer [type]
(exit status 2)

A Record is read with a pattern. r => {mean:, count: n} binds mean and n. Field order does not matter, and a Record is never a value of a class, even when the fields match.

def stats(xs)
  total = Array.sum(xs)
  {count: Array.size(xs), total: total, mean: total / Array.size(xs)}
end

r = stats(Any[3, 5, 10])
p(r)
r => {mean:, count: n}        # take fields apart by name
puts(mean)
puts(n)
p({y: 2, x: 1})               # field order does not matter
r => {median:}
$ sake records.sake
records.sake:12:7: error: the pattern needs field `median`, but the value is {count: Integer, mean: Integer, total: Integer} [type]
(exit status 2)

In Sake, [] and {} are not growable collections. Ruby code that grows them stops with a hint:

squares = []                  # a Tuple of length 0, not an Array
Integer.times(3) { |i| Array.push(squares, i * i) }
$ sake ruby_habits.sake
ruby_habits.sake:2:24: error: Array.push: argument 1 must be Array, but is [] [type]
  hint: `[...]` is a Tuple with a fixed length; for a growable Array, write `Any[...]`
(exit status 2)
counts = {}
names = {"a" => 1}
$ sake empty_braces.sake
empty_braces.sake:1:10: error: `{}` is an empty Record, not a Hash
  hint: for a Hash, write `Hash.new`; for a growable list, `Any[]`
empty_braces.sake:2:10: error: `{"a" => ...}` is not a Hash in Sake: `{name: value}` makes a Record
  hint: for a Hash, write `Hash.of("a" => ...)`
(exit status 2)

Indexing

x[k] runs the [] of x's type, and x[k] = v its []=. Array, Hash, String, Tuple, and MatchData have them.

  • A miss gives nil, as in Ruby, so with --strict you check a[i] before using it. Array.fetch raises an error instead.
  • A Tuple's length is part of its type, so reading outside it is an error.
  • Writing a Tuple position needs a value of that position's type.
words = String.split("the quick brown fox", " ")
p(words[0])
p(words[-1])
p(words[10])                   # outside the Array: nil, as in Ruby
words[1] = "slow"
p(words)

counts = Any[0, 0, 0]
counts[1] += 1
p(counts)

t = [1, "one"]
t[0] = 2                       # a Tuple keeps the type of each position
p(t)

best = nil
best ||= Array.max(Any[3, 9, 4])
p(best)
p(t[2])                        # a Tuple's length is part of its type
$ sake indexing.sake
indexing.sake:19:3: error: Indexable.[]: the index is outside the Tuple [Integer, String] [type]
(exit status 2)

Point[1, 2] means "an Array of Point". It is not Ruby's shorthand for Point.new(1, 2). When the mistake is visible in the source, it is reported before running:

Point = Struct.new(:x, :y)
ps = Point[1, 2]
$ sake typed_array_errors.sake
typed_array_errors.sake:2:12: error: Point[]: element 1 must be Point, got Integer
  hint: Point.new(1, 2) creates one Point; Point[...] is an Array of Point
(exit status 2)

More on Arrays: Array.new(n, v) and Array.new(n) { |i| … }, + - *, element assignment as a multiple-assignment target (a swap), two indexes s[start, length], and a start value for sum, which an empty Array gives instead of the Integer 0:

grid = Array.new(3, 0)                 # three zeros; Array.new(3) { |i| i * i } computes each
squares = Array.new(4) { |i| i * i }
p([grid, squares])
p([Any[1, 2] + Any[3], Any[1, 2, 2, 3] - Any[2], Any[0] * 3])
a = Any[1, 2, 3]
a[0], a[2] = a[2], a[0]                # swap: elements (and fields) can be assignment targets
p(a)
p("sake-lang"[0, 4])                   # two indexes: start and length
key, value, extra = String.split("lang=sake", "=")   # missing elements are nil, as in Ruby
p([key, value, extra])
prices = Float[1.25, 2.5]
p(Array.sum(prices, 0.0))              # the start value: an empty Array sums to 0.0, not 0
$ sake arrays_more.sake
[[0, 0, 0], [0, 1, 4, 9]]
[[1, 2, 3], [1, 3], [0, 0, 0]]
[3, 2, 1]
"sake"
["lang", "sake", nil]
3.75

Tuples tagged by a Symbol

An Array of Tuples whose first element is a Symbol tag keeps each kind apart. Testing the tag, after |kind, arg| takes a Tuple apart (or with case t[0]), tells the type of the rest:

events = Tuple[]                         # an Array of Tuples, filled below
Array.push(events, [:deposit, 500])
Array.push(events, [:note, "rent due"])
Array.push(events, [:withdraw, 120])
balance = 0
Array.each(events) do |kind, arg|
  case kind                              # the Symbol tag tells which Tuple it is,
  in :deposit then balance += arg        # so here arg is an Integer
  in :withdraw then balance -= arg
  in :note then puts("note: #{String.upcase(arg)}")  # and here a String
  end
end
p(balance)
$ sake tagged.sake
note: RENT DUE
380

Where a type is worth writing

A function needs no type: its operations say what it accepts. A class's field needs none either: its type comes from the values written to it, which T.new and T.set_x show. The one place a type pays is an Array that is filled later. Made with Any[], its element type is whatever gets pushed, so a wrong value is reported where the elements are used, far from where it went in:

def labels(n)
  out = Any[]                          # any element type: it is whatever gets pushed
  Integer.times(n) { |i| Array.push(out, "#{i}") }
  out
end
p(Array.sum(labels(3)))                  # the mistake shows up here, far from the push
$ sake elements_untyped.sake
elements_untyped.sake:6:3: error: Array.sum: an element must be Integer|Float|Rational|Complex, but is String [type]
(exit status 2)

Made with its element type, Integer[] (or Float[], Point[], Tuple[]), every push is checked where it happens. The type is written on the operation that makes the Array, not on a variable:

def labels(n)
  out = Integer[]                        # an Array of Integer
  Integer.times(n) { |i| Array.push(out, "#{i}") }  # the mistake shows up where it is made
  out
end
p(Array.sum(labels(3)))
$ sake elements_typed.sake
elements_typed.sake:3:26: error: Array.push: an element must be Integer, but is String [type]
  hint: reached by the call at line 6
(exit status 2)

Rules

  • An Array of T checks each element on creation and on push, append, and concat. There is no conversion, so an Integer cannot go into Float[].
  • map, select, sort, and similar operations return an Array with no declared element type.
  • The contents of a Tuple or Record may be replaced by values of the same type. A Tuple is written with t[i] = v; a Record has no write syntax yet. The length is part of the type.

Strings and text

String and Regexp operations follow Ruby's, including the positions that a parser needs: String.index(s, t, start), Regexp.match(re, s, pos) (with \G anchored at pos), String.byteslice, and sub/gsub with a replacement String, a Hash, or a block.

s = "key=value; name=Sake; lang=ruby"
p(String.index(s, "="))            # a position
p(String.index(s, "=", 4))         # searching from position 4
m = Regexp.match(/(\w+)=(\w+)/, s, 11)   # matching from a position
p([m[1], m[2]]) if m
p(String.gsub(s, /(\w+)=/) { |kv| String.upcase(kv) })  # a block gives each replacement
p(String.gsub("cat hat", /[ch]at/, Hash.of("cat" => "dog", "hat" => "cap")))
p(String.split(s, "; "))
p(format("%-6s|%5.2f|%03d", "ab", 3.14159, 7))
p(String.bytes("é"))                  # UTF-8 bytes
p(String.unpack("\x01\x00\x02\x00", "v*"))  # binary data, as Ruby's unpack
p(Array.pack(Any[72, 105], "C*"))
$ sake strings.sake
3
15
["name", "Sake"]
"KEY=value; NAME=Sake; LANG=ruby"
"dog cap"
["key=value", "name=Sake", "lang=ruby"]
"ab    | 3.14|007"
[195, 169]
[1, 2]
"Hi"

Rules

  • There are no globals such as $1 or $~. Keep the MatchData: m = String.match(s, re), then m[1].
  • "#{x}", puts, and format("%s") use a type's own to_s.
  • Strings of incompatible encodings meeting (binary bytes next to UTF-8 text) raise EncodingError. Broken UTF-8 given to a regexp raises ArgumentError.
  • String.unpack, unpack1, and Array.pack read and write binary data with Ruby's formats.

Hash, Set, Range

Hash, Set, Symbol, and Range work as in Ruby. Their operations carry the type like everything else (Hash.each, Range.to_a), while h[k], 1..5, and :name are written as in Ruby.

  • Hash.of(k => v) and Hash.new(0) make a Hash, because {…} is a Record.
  • A missing key gives nil, or the default given to Hash.new.
  • A block over a Hash receives [key, value], which |k, v| takes apart.
text = "the cat and the hat and the bat"
counts = Hash.new(0)
Array.each(String.split(text, " ")) { |w| counts[w] += 1 }
p(counts)
top = Hash.max_by(counts) { |w, n| n }
p(top)
p(Array.tally(String.chars("banana")))

ages = Hash.of("ann" => 31, "bob" => 25)
ages["cy"] = 40
Hash.each(ages) { |name, age| puts(format("%-4s %3d", name, age)) }
p(ages["nobody"])                        # a missing key gives nil
p(Hash.fetch(ages, "nobody", 0))

p(Range.to_a(1..5))
p(Range.select(1..20) { it % 7 == 0 })
p(Any[10, 20, 30, 40][1..2])

seen = Set.new
Array.each(Any[3, 1, 3, 2, 1]) { Set.add(seen, it) }
p(seen)

m = String.match("2026-10-01", /(\d+)-(\d+)-(\d+)/)
p(MatchData.captures(m)) if m
p(String.scan("a1b22c333", /\d+/))
p(:ok)
$ sake ruby_types.sake
{"the" => 3, "cat" => 1, "and" => 2, "hat" => 1, "bat" => 1}
["the", 3]
{"b" => 1, "a" => 3, "n" => 2}
ann   31
bob   25
cy    40
nil
0
[1, 2, 3, 4, 5]
[7, 14]
[20, 30]
Set[3, 1, 2]
["2026", "10", "01"]
["1", "22", "333"]
:ok

Rules

  • Hash keys and Set elements compare as == does: numbers, Strings, Symbols, true, false, nil, Time, and Tuples, Records, Arrays, Hashes, Sets and values of a class made of these. A class that defines its own == (or <=> with Comparable) cannot be a key. A Tuple or Record key is copied when stored.
  • Hash.new { … } is not available, because a block cannot be stored.
  • a[1..2] and s[1..3] slice an Array or a String.

nil

A value that may be absent is simply nil: its type is nil | T, with no Option wrapper. Check it with if x, while x, x != nil, or return unless x. When a nil reaches an operation that needs something else, the error tells you where it can come from.

Node = Struct.new(:value, :next)

def build(n)
  head = nil
  Integer.downto(n, 1) { |i| head = Node.new(i, head) }
  head
end

def sum(node)
  total = 0
  while node                      # nil and false are the only falsy values
    total += Node.value(node)
    node = Node.next(node)
  end
  total
end

def second(node)
  rest = Node.next(node)
  return 0 if rest == nil
  Node.value(rest)
end

list = build(4)
puts(sum(list))
puts(second(list))
puts(Node.next(list) == nil)
puts(Node.next(list).next.next.next.value)
$ sake nil.sake
10
2
false
nil.sake:28: in <main>: TypeError: Node.value: argument 1 must be Node, got nil
  hint: Node.next may be nil (nil is stored at line 5)
  hint: check the value first: `if x`, `while x`, `return unless x`, or `x != nil`
(exit status 1)

By default (level 1), an unchecked nil is caught when it runs. --strict (level 2) reports every unchecked use before running. A local variable you have tested counts as checked. A field you read again does not, because fields are mutable.

Node = Struct.new(:value, :next)

def second(node) = Node.next(node).value

def second_checked(node)
  rest = Node.next(node)
  rest ? Node.value(rest) : 0
end

list = Node.new(1, Node.new(2, nil))
puts(second(list))
puts(second_checked(list))
$ sake strict.sake
2
2
$ sake --strict strict.sake
2
2
You writeThe local x is known to be
if x, while x, x && …not nil, where x was truthy
x != nil, x == nilnot nil, or nil, in the matching branch
return unless x, next if x == nilnot nil after that line

Exceptions

raise, rescue, else, ensure, and retry work as in Ruby. An exception type is declared with Exception.new: it is a class whose first field is message. class B < A is a copy, so rescue A does not catch B; to catch several types together, include a module in them and write rescue M.

ParseError = Exception.new(:line)        # an exception type: message, then its own fields

def parse(s)
  raise ParseError.new("empty input", 1) if String.empty?(s)
  raise ArgumentError, "too long" if String.size(s) > 5
  String.to_i(s)
end

Array.each(Any["", "123456", "42"]) do |s|
  begin
    p(parse(s))
  rescue ParseError => e                 # list the types to rescue (a module groups several)
    puts("line #{ParseError.line(e)}")
  rescue ArgumentError => e
    puts(Exception.message(e))
  ensure
    puts("--")
  end
end

def safe_div(a, b)
  a / b
rescue ZeroDivisionError
  0
end
p(safe_div(1, 0))
v = Hash.of("a" => 1).fetch("b") rescue -1
p(v)
$ sake exceptions.sake
line 1
--
too long
--
42
--
0
-1

The checker tracks which exceptions each function may raise. It warns about a rescue that can never match (unused-rescue; the program still runs); level 4 also reports a raise that nothing rescues:

$ sake --strict=4 exceptions_flow.sake
exceptions_flow.sake:11:1: warning: rescue OtherError: the begin body never raises OtherError [unused-rescue]
  hint: remove this rescue, or raise OtherError in the body
exceptions_flow.sake:5:3: error: raise: ParseError may reach the top level without being rescued [unrescued]
  hint: rescue it, or check with a level below 4
(exit status 2)

Rules

  • raise "msg" raises RuntimeError. raise T, "msg" works when T has only message; otherwise write raise T.new(…).
  • rescue => e catches everything rescuable; e is a union, so narrow it with case e in …. Exception.message(e) reads any exception's message.
  • SystemStackError, LocalJumpError, and NotImplementedError are program errors: they cannot be rescued. TypeError and NoMatchingPatternError (a failed x => T or case/in) can be, as in Ruby.

Values computed once

Sake has no value constants such as PI = 3.14: a constant would be a value with no operation naming its type. A function gives the value instead, def pi = 3.14159. When the value is costly to build, such as a table, once { … } computes it the first time its place in the program runs, and keeps it for the whole program:

# Sake has no value constants. `once { ... }` computes a value the first time its place runs and
# keeps it for the whole program: a table built once, read by every call.
def squares = once do
  puts("building the table")
  Array.map(Range.to_a(0..9)) { |i| i * i }
end
def weekday(i) = once { String["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] }[i % 7]

p(squares[4])
p(Array.sum(squares))     # the same table: "building" is printed once
p(weekday(1))
p(weekday(10))
$ sake once.sake
building the table
16
285
"Mon"
"Wed"

Rules

  • Each once in the source is one value, shared by every call and every thread.
  • A once that needs its own value while computing it raises SystemStackError.

Programs

Files, libraries, and arguments

require "lib/web" reads lib/web.sake next to the requiring file, once. When there is no such file, it reads Sake's library, sakelib/. The argument is a string literal and require is a statement at the top of a file, so the files of a program are decided before running. A required file's top-level statements run first, and every message names its file.

# require "json" reads json.sake next to this file, or else Sake's library sakelib/json.sake.
require "json"
require "base64"

data = JSON.parse("{\"name\": \"Sake\", \"tags\": [\"ruby\", \"types\"]}", symbolize_names: true)
p(data)
puts(JSON.generate(Hash.of(ok: true, n: Any[1, 2])))
puts(JSON.pretty_generate(Hash.of("a" => Any[1, 2])))
p(Base64.strict_encode64("Sake"))
p(ARGV)             # the program's arguments: bin/sake libraries.sake a b
warn("a warning goes to stderr")
$ sake libraries.sake
{name: "Sake", tags: ["ruby", "types"]}
{"ok":true,"n":[1,2]}
{
  "a": [
    1,
    2
  ]
}
"U2FrZQ=="
[]
a warning goes to stderr

sakelib/ holds ports of Ruby's standard library, each tested against Ruby's own output: json, csv, base64, digest, zlib, strscan, shellwords, uri, cgi, erb, optparse, logger, benchmark, date, matrix, prime, abbrev, and tsort. Their calls are Ruby's with the type in front: JSON.parse(s, symbolize_names: true), CSV.parse(text, headers: true), Digest::SHA256 as SHA256.hexdigest(s). Each library's notes (sakelib/notes/) list what differs from Ruby.

Rules

  • ARGV is the Array of the program's arguments (bin/sake prog.sake a b). It is written like Ruby's constant but is an operation: every use gives the same Array.
  • exit(status) ends the program (ensure clauses run); warn(msg) writes to stderr.
  • A require inside a function or a block, or with a computed name, is an error before running.

Input and output

puts, print, p, and gets work as in Ruby. To choose where text goes, take an IO value: IO.stdout, IO.stderr, IO.stdin, or a file from File.open. There are no globals such as $stdout; as with ARGV, the value comes from an operation. Files and streams are one type, IO, so one function writes to either.

# IO values: IO.stdin, IO.stdout, IO.stderr, and File.open. One type, IO, for all of them.
def report(io, title, rows)
  IO.puts(io, "== #{title}")
  Array.each(rows) { |k, v| IO.puts(io, format("%-5s %3d", k, v)) }
end

rows = Any[["ann", 31], ["bob", 25]]
report(IO.stdout, "to stdout", rows)
path = "io_example.tmp"
File.open(path, "w") { |f| report(f, "to a file", rows) }  # closed after the block
File.open(path) do |f|
  p(IO.gets(f))
  p(IO.readlines(f))
end
File.delete(path)
$ sake io.sake
== to stdout
ann    31
bob    25
"== to a file\n"
["ann    31\n", "bob    25\n"]

Rules

  • File.open(path, mode = "r") gives an IO; with a block, it gives the block's value and closes the file after the block.
  • IO.puts, print, write, gets, read, readlines, each_line, eof?, flush, and close take the IO first. File.read, File.write, and File.readlines work on a path.
  • Reading or writing a closed IO raises IOError. Writing to IO.stderr flushes stdout first, so the two keep their order.

Threads and sockets

Thread.new { … } runs a block in a new thread. The block shares the variables around it, as every block does; Queue passes values between threads and Mutex.synchronize guards shared state. TCPServer and Socket give a server and a client, enough for a small web server written in Sake.

# Threads share the variables around their block; a Queue passes values between them.
q = Queue.new
workers = Array.map(Range.to_a(1..3)) do |id|
  Thread.new do
    sum = 0
    Range.each(1..(id * 100)) { |i| sum += i }
    Queue.push(q, [id, sum])
  end
end
Array.each(workers) { |t| Thread.join(t) }
results = Array.new(3) { Queue.pop(q) }.sort
p(results)
lock = Mutex.new
count = 0
ts = Array.map(Range.to_a(1..4)) { Thread.new { Integer.times(1000) { Mutex.synchronize(lock) { count += 1 } } } }
Array.each(ts) { |t| Thread.join(t) }
p(count)
$ sake threads.sake
[[1, 5050], [2, 20100], [3, 45150]]
4000

Rules

  • Thread.value(t) gives the block's value, and the checker knows its type. An error in a thread is raised again by Thread.value or Thread.join.
  • TCPServer.new(host, port), TCPServer.accept, Socket.connect(host, port), Socket.gets, read, write, and close; network errors raise IOError.

Seeing the types

--types runs a whole-program type inference without running the program. For every operation that checks its argument, it reports one of the following:

  • proven: the check always passes;
  • partial: the value's type is a union, so the check is still needed;
  • error: the check always fails;
  • unknown: the inference could not tell.

It also lists the element type of each Array and the type of each field. None of these types were written in the program. The playground (ide/) shows the same results in its Types tab and on hover.

Item = Struct.new(:name, :price, :qty)

def total(items) = Array.reduce(items, 0) { |acc, it| acc + Item.price(it) * Item.qty(it) }

items = Item[Item.new("apple", 120, 3), Item.new("pear", 200, 1)]
puts(total(items))
cheap = Array.select(items) { Item.price(it) < 150 }
puts(Array.map(cheap) { Item.name(it) }.join(", "))
$ sake types.sake
560
apple
$ sake --types types.sake
passes: 2
checks: proven=13 partial=0 error=0 unknown=0
sites: arrays 3, structs 2 (Item 2); instantiations 1
arrays:
  L5: declared Item
  L7 Array.select: init Item -> Item
  L8 Array.map: init String -> String
fields:
  Item@L5.name: String
  Item@L5.price: Integer
  Item@L5.qty: Integer
  Item@L5.name: String
  Item@L5.price: Integer
  Item@L5.qty: Integer

On the strict program from the nil section, it finds the one unchecked use:

$ sake --types strict.sake
passes: 2
checks: proven=4 partial=0 error=0 unknown=0
sites: arrays 0, structs 2 (Node 2); instantiations 2
arrays:
fields:
  Node@L10.value: Integer
  Node@L10.next: nil
  Node@L10.value: Integer
  Node@L10.next: Node@L10

A complete program

A bank account with a history. Its fields are read-only from outside (attr_reader); the functions in its class change them through @balance and @history. The history is made with Tuple[] and holds [kind, amount] Tuples tagged by a Symbol, which a do |kind, amount| block takes apart.

class Account   # read-only from outside; changed only below
  attr_reader owner, balance, history
  def open(owner) = Account.new(owner, 0, Tuple[])     # the history holds Tuples

  def deposit(acct, amount)
    @balance += amount
    Array.push(@history, [:deposit, amount])
    acct
  end

  def withdraw(acct, amount)
    if @balance < amount
      Array.push(@history, [:refused, amount])
      return false
    end
    @balance -= amount
    Array.push(@history, [:withdraw, amount])
    true
  end

  def statement(acct)
    puts("Statement for #{@owner}")
    Array.each(@history) do |kind, amount|
      puts(format("  %-10s%6d", kind, amount))
    end
    puts(format("  %-10s%6d", "balance", @balance))
  end
end

alice = Account.open("Alice")
Account.deposit(alice, 500)
Account.withdraw(alice, 120)
Account.withdraw(alice, 1000)
Account.statement(alice)
$ sake bank.sake
Statement for Alice
  deposit      500
  withdraw     120
  refused     1000
  balance      380

Reference

Notation

Everything Sake writes differently from Ruby, on one page. The rest is Ruby's syntax.

WrittenMeans
require "lib/web" · require "json" · require "sql/*"Reads lib/web.sake next to this file, else sakelib/json.sake, once, before running; a glob reads every matching file; a string literal at the top level only
T.op(x, args…)The operation op of type (or module) T, subject first. A call on a value, x.op, is rejected.
x.T.op(args…)The same as T.op(x, args…): a chain that reads left to right
_The value of the previous statement in the same body
(A|B).op(x)The op of x's type, which must be A or B (checked before running)
M.op(x) (M a module)A module_function of M, or the op of x's type, which includes M (dispatch)
f(x)The enclosing class or module's f, else a top-level function, else Kernel's
a + b, -a, a[k]The operator of a's type; your own type joins with include Arithmetic (Comparable, Bitwise, Indexable) and def +(a, b)
!xx ? false : true
class C · attr_reader a · attr_accessor bA type with fields: C.new(a, b), C.a(c), c.C.b = v; field names are written bare; def initialize(c) sets the fields new left out
def initialize(c)Runs after C.new stores the fields: checks and conversions
class B < AA's definitions written again in B (fields, functions, includes); not inheritance, a B is not an A
class E < ExceptionAn exception type: message is its first field
module A · class B inside it · A::B.f(x)Namespaces nest: B is A::B; inside A, a bare B means A::B
Enum.map(x) { } · include EnumThe prelude's Enumerable: dispatches to Array, Hash, Set, Range, or a class with include Enum and def each
C = Struct.new(:a, :b)Shorthand for class C with attr_accessor a, b
E = Exception.new(:line)An exception type with fields message and line
@a · @a = v · @a += vInside a function of class C: field a of the function's first argument (C.a(c), C.set_a(c, v))
def C.f(c)Shorthand for def f(c) inside class C
[a, b]A Tuple: fixed length, a type per position
{x: a, y: b}A Record: its fields and their types; read with r => {x:, y:}
Any[a, b] · T[a, b]A growable Array; with T, an Array of T whose writes are checked
Hash.of(k => v) · Set.of(a, b)A Hash, a Set ({} is a Record)
x in T · case x in T then …A type test; the branch knows x is a T
x => T · r => {x:, y:}A type assertion (afterwards x is a T); taking a Record apart
first, *rest = xs · |a, *rest| · f(*xs)The rest as an Array; a splat only into a rest parameter (a built-in's, or a function's *rest)
def area(s) = raise(NotImplementedError) (in a module)Each type that includes the module must define area
def f(a, b = 1, c:, d: 2) · f(x, d: 5) · f(x, c:)Optional and keyword parameters, as in Ruby; keywords are matched by name before running
block_given? · def f(xs, &b) = g(xs, &b)Whether a block was given; passing the function's block on (never a value)
once { … }A value computed the first time this place runs, then kept: in place of constants
ARGV · IO.stdout · IO.stderr · IO.stdinThe program's arguments and streams, given by operations (no globals)

What Sake rejects

Sake rejects anything that would hide which code runs, and anything not designed yet. All of it is reported together, before running:

Point = Struct.new(:x, :y)
PI = 3.14159
class Point
  def origin = self
end
eval("1 + 1")
$ sake forbidden.sake
forbidden.sake:2:1: error: Sake has no value constants; a constant names a class (`Point = Struct.new(:x, :y)`, `Err = Exception.new(:msg)`), and a value goes in a function
  hint: define a function instead: `def pi = 3.14159`
forbidden.sake:4:16: error: Sake has no `self`
forbidden.sake:6:1: error: `eval` is not allowed in Sake (it defeats static analysis)
(exit status 2)

Rules

  • Never allowed: self, @x outside a function of a class, send, method_missing, define_method, the eval family, instance_variable_get/set, const_get/set, and subtypes: a value has exactly one type.
  • By design: there are no value constants, and no globals. Define a function instead (def pi = 3.14159), with once { … } for a value built once (Values computed once); ARGV and IO.stdout are operations.

Built-in operations

Names follow Ruby's core library. The subject is the first argument. Operations marked "block" require one.

Kernel

OperationReturnsNotes
puts(*xs)nilPrints as Ruby does. Arrays and Tuples print one element per line.
print(*xs)nilNo newline
p(x)xRuby's inspect format, e.g. #<struct Point x=1, y=2>
getsString or nilA line of stdin
format(fmt, *xs) · Integer(s) · Float(s)String · Integer · FloatAs Ruby; a bad number raises ArgumentError
warn(*xs) · exit(status) · ARGVnil · — · Array of Stringstderr; ends the program; the arguments
dup(x)a copy of xas Ruby's obj.dup; a type that defines dup gets its own
loop { } · Math::PI · Float::INFINITYthe break's value · Floatconstants are read as operations, like ARGV
Arithmetic.round(x, [n]) · abs · to_f · floor · ceilper x's typeany of Integer, Float, Rational, as Ruby's x.round
once { } · block_given?the block's value · true / falseSee once and Blocks

Integer

OperationReturns
+ - * / % ** (both Integer)Integer
< <= > >= == != (both Integer)true / false
to_s · to_fString · Float
abs · succ · predInteger
even? · odd? · zero?true / false
times(n) { |i| }n (block)
upto(a, b) { |i| } · downto(a, b) { |i| }a (block)

Float

OperationReturns
+ - * / % ** (both Float)Float
< <= > >= == != (both Float)true / false
to_sString
to_i · floor · ceil · round(f)Integer
round(f, digits) · absFloat
nan?true / false

String

OperationReturns
+(a, b) · *(s, n)String
== != < <= > >= (both String)true / false
length · size · count(s, chars) · to_iInteger
to_fFloat
upcase · downcase · capitalize · swapcase · reverse · strip · lstrip · rstrip · chomp · to_sString
sub(s, pat, repl) · gsub(s, pat, repl)String; pat a String or Regexp, repl a String (with \1), a Hash, or a block
ljust(s, n, [pad]) · rjust(s, n, [pad])String
empty? · include?(s, t) · start_with?(s, t) · end_with?(s, t)true / false
chars · lines · split(s, [sep])Array of String
index(s, t, [start]) · rindex · byteindexInteger or nil
match(s, re, [pos]) · match? · scan(s, re)MatchData or nil · true / false · Array
bytes · byteslice(s, i, [n]) · unpack(s, fmt) · bArray of Integer · String or nil · Array · String
each_char(s) { |c| }s (block)

Array

OperationReturns
Any[…] · T[…]a new Array (of T)
length · sizeInteger
empty? · include?(a, x)true / false
push(a, *xs) · append(a, *xs) · concat(a, b)a, changed in place
join(a, [sep])String
sum(a, [start])the sum (elements must be numbers); start, default the Integer 0, when empty
Array.new(n, [v]) · Array.new(n) { |i| }a new Array of n elements
reverse · sort · take(a, n) · drop(a, n)a new Array
each(a) { |x| } · each_with_index(a) { |x, i| }a (block)
map · select · filter · reject · sort_bya new Array (block)
any? · all? · none?true / false (block)
count(a) { |x| }Integer (block)
reduce(a, init) { |acc, x| } · injectthe accumulated value; init is required (block)
at(a, i) · first · last · min · max · pop · shiftan element, or nil (pop and shift remove it)
first(a, n) · last(a, n) · fetch(a, i, default)a new Array · the element or default
each_cons(a, n) · each_slice(a, n) · each_with_index(a) without a blockan Array of slices / of [x, i] (Ruby's each_cons(2).map)
fetch(a, i)the element, or IndexError
unshift(a, *xs)a, changed in place
find · detect · min_by · max_byan element, or nil (block)
index(a, x) · find_index(a) { |x| }Integer or nil

Hash, Set, Range, Regexp, IO

OperationReturns
Hash.of(k => v) · Hash.new(default) · Set.of(…)a new Hash or Set
Hash.fetch(h, k, [default]) · dig · delete · keythe value (or KeyError) · value or nil
Hash.keys · values · to_a · map · sort_byArray (pairs are [k, v] Tuples)
Hash.each · select · transform_values · merge · count · min_byas in Ruby
Set.add · include? · union · | & -as in Ruby
Range.each · to_a · map · select · sum · include? · stepas in Ruby (iteration needs an Integer start)
String.match · Regexp.match · String.scan · =~MatchData or nil · Array · Integer or nil
gets · File.read · File.readlines · File.write · File.exist?String or nil · String · Array · Integer · true/false
IO.stdin · IO.stdout · IO.stderr · File.open(path, [mode]) { |f| … }IO values; IO.puts(io, …) · IO.gets(io) · IO.readlines(io) · IO.close(io) …
Thread.new { } · Thread.value · Queue.new · push · pop · Mutex.synchronize { }as in Ruby
Array.tally · group_by · to_h · zip · partition · each_slice · uniq · flat_mapas in Ruby (Tuples where Ruby gives pairs)

The complete list of the built-in operations is in docs/builtins.md, generated from the interpreter.

Tuple and Math

OperationReturns
Tuple.length · Tuple.sizeInteger
Tuple.max([a, b]) · Tuple.min · Tuple.minmaxthe largest / smallest of a few values (Ruby's [a, b].max); never nil
Math.sqrt · cbrt · sin · cos · tan · atan · exp · log · log2 · log10 (Integer or Float)Float
Math.atan2(y, x) · Math.hypot(x, y)Float

Errors

Before running (exit status 2)

All errors are reported at once, in source order, and nothing runs.

FILE:LINE:COLUMN: error: MESSAGE
  hint: SUGGESTION

Errors found before running:

  • syntax errors;
  • unknown types, operations, and functions;
  • a wrong number of arguments, and an unknown, repeated, or missing keyword argument;
  • a require that is not a string literal at the top level, or a file that cannot be read;
  • a block given where none is taken, or missing where one is required;
  • calls on values;
  • forbidden or unsupported syntax;
  • duplicate definitions;
  • a literal of the wrong type in T[…];
  • by default (level 1), a value whose type does not fit the operation, such as "" + 1, and a yield reached without a block;
  • with --strict (level 2), a value that may be nil used without a check;
  • with --strict=3, the result of x[k] used without a check, and a case whose literal branches may miss a value.

While running (exit status 1)

FILE:LINE: in FUNCTION: KIND: MESSAGE
  from FILE:LINE: in CALLER
  hint: SUGGESTION
KindWhen
TypeErrorAn operation got the wrong type, an operator the left operand's type does not support, a write to an Array of T has the wrong type, or a multiple assignment is not from a Tuple or an Array
IndexErrorA Tuple index outside the Tuple, Array.fetch outside the Array, or a write past the end of an Array of T
ArgumentErrorA block parameter count mismatch, a negative size, Integer ** negative, or sort on values that cannot be compared
ZeroDivisionErrorInteger / or % by zero
FloatDomainError · Math::DomainErrorNaN or Infinity converted to Integer; for example, Math.sqrt(-1)
KeyErrorHash.fetch of a missing key; a Record pattern naming a missing field
IOError · EncodingErrorA file or socket error, or a closed IO · Strings of incompatible encodings meeting
RuntimeErrorraise "message"
SystemStackErrorRecursion deeper than 10,000, or a once that needs its own value
LocalJumpErrorA yield without a block (cannot be rescued)

Not yet supported

Each of these is rejected before running. Most are waiting on a design decision.

  • Writing to Record fields
  • The type scope Integer.(a + b)
  • case/when (use case/in), %w[], %i[]
  • for: not planned; iterate with an operation such as Range.each(1..3) { |i| … }
  • Patterns other than types, literals, alternatives, Record shapes and Tuple patterns [P, Q] (*rest in a Tuple pattern, find patterns, pins, guards, x => Integer => n)
  • Storing a block: proc, lambda, ->, and &b as a value. A prototype type-checks them at a linear cost, but blocks kept in one container are then checked with each other's argument types, which gives false reports; blocks stay second-class for now