Core language features

Variables

Unison variables can be defined at the top-level of a program. There is no keyword to introduce a value and all values are immutable.

The type signature of a value or function appears above the definition instead of interspersed with the names of the function parameters. Both Scala and Unison support type inference, so these type signatures are optional.

Basic types

aNat : Nat
aNat = 123

anInt : Int
anInt = +789

aText : Text
aText = "A Text value"

aChar : Char
aChar = ?a
val anInt : Int = 123

val aString : String = "A String value"

val aChar : Char = 'a'

In Scala, positive integers are created without the +. In Unison, Nat is the type for positive integers, all Int literals in Unison must include + or -.

aList : [Int]
aList = [+1, +2, -3, -4, +0]

aMap : Map Text Nat
aMap = Map.fromList [("a", 1), ("b", 2), ("c", 3)]

someVal : Optional Boolean
someVal = Some true
noneVal = None

eitherVal : Either Text Nat
eitherVal = Right 2468 |> Either.mapRight (n -> n + 1)

theUnitVal : ()
theUnitVal = ()

notImplemented : [Boolean]
notImplemented = todo "a placeholder"

Type arguments in signatures are delimited by spaces, not square brackets.

val aList : List[Int] = List(1, 2, -3, -4, 0)

val aMap : Map[String, Int] = Map(("a", 1), ("b", 2), ("c", 3))

val someVal : Option[Boolean] = Some(true)
val noneVal = None

val eitherVal : Either[String, Int] = Right(2468).map(_ + 1)

val theUnitVal : Unit = ()

val notImplemented : List[Boolean] = ???

var aVariable = 0
aVariable + 3

Unison does not have an analog to the var keyword in Scala.

Functions

Functions in Unison type signatures are represented with the single arrow ->. Multiple arguments are represented with currying instead of comma-delimited argument lists.

There is no special keyword for differentiating a variable from a function in Unison.

splitDigitsOn : Char -> Text -> [Text]
splitDigitsOn delim text =
  Text.split delim text
    |> List.map (cell -> Text.filter isDigit cell)

Unison is a whitespace-significant language, so the function body is scoped by newlines and indentation.

Lambdas are introduced with parens: (arg1 arg2 -> impl). The arrow -> separates the arguments of the lambda from its body.

The |> operator emulates the chaining of .split and .map in Scala.

def splitDigitsOn(delim : Char, text : String) : List[String] = {
  text.split(delim)
    .map(cell => cell.filter(_.isDigit))
    .toList
}

Scala uses dot notation syntax, as in text.split, to call split on the text argument. Unison takes the text value as an argument to another function, Text.split.

It's a common mistake to forget to supply the last argument to Unison functions like List.map because of this convention.

Calling functions

During function application, arguments are separated by spaces:

digits = splitDigitsOn ?| "abc12|def34|56|78"

Since commas are not used to explicitly separate arguments, parenthesis disambiguate the order in which functions should be applied.

Arguments are provided in parentheses, separated by commas:

val digits = splitDigitsOn('|', "abc12|def34|56|78")
digits = splitDigitsOn ?| ("abc12|" ++ "def34|56|78")
val digits = splitDigitsOn('|', "abc12|" ++ "def34|56|78")

Generic types

Generic types are represented with lowercase letters in Unison. You do not need to introduce type variables for a polymorphic function in square brackets before using them.

identity : a -> a
identity a = a
def identity[A](a: A): A = a

Delayed evaluation

Unison delayed computations are functions with no arguments (thunks.)

In type signatures, they’re indicated with the single quote syntax sugar 'a, short for () -> a. In the body of a function, they are introduced with the ' or do keyword.

computeTwice : '{IO, Exception} Nat -> Nat
computeTwice x =
  x() + x()

expensiveComputation : '{IO, Exception} Nat
expensiveComputation = do
  printLine("Running...")
  42

computeTwice(expensiveComputation())

-- Prints the message twice

The syntax for forcing a thunk in Unison is x() or !x.

Scala has non-forced function arguments using call-by-name parameters (=> T). Scala defers the value every time the parameter is used inside the function.

def computeTwice(x: => Int): Int = x + x

def expensiveComputation(): Int = {
  println("Running...")
  42
}

computeTwice(expensiveComputation())

// Prints the message twice

Scala’s lazy values are different from delayed computations because they memoize the value once evaluated.

lazy val expensiveComputation: Int = {
  println("Computing...")
  42
}

println(expensiveComputation)
println(expensiveComputation)

// Prints the message only once

Organizing code

Packages

Unison uses namespaces to organize code, which are similar to Scala packages.

namespace models

type User = User Text Nat

User.toJson : User -> Json
User.toJson user = todo "unimplemented"

type UserPreferences = UserPreferences [Text]

It's more common to see namespaces fully prefixed by their dot-separated name segments in a scratch.u file:

type models.User = User Text Nat

models.User.toJson : User -> Json
models.User.toJson user = todo "unimplemented"

type models.UserPreferences = UserPreferences [Text]
package models

case class User(name: String, age: Int)

object User {
  def toJson(user: User): String = ???
}

case class UserPreferences (preferences: List[String])

Imports

Unison uses the use keyword to import definitions while Scala uses the import keyword. Both Unison and Scala support imports at the top-level of the file and scoped to definitions.

-- imports everything in the `models.User` namespace
use models.User
-- imports the `User` and `UserPreferences` namespaces
use models User UserPreferences
-- imports specific terms from the `models.User` namespace
use models.User toJson fromJson
sqrtplus1 : Float -> Float
sqrtplus1 x =
  use Float sqrt
  sqrt x + 1.0
// imports everything in the `models` package.
import models.*
// imports specific members from the `models` package
import models.{User, UserPreferences}
// Scala supports renaming imports, Unison does not.
import models.UserPreferences as UPrefs
def sqrtplus1(x: Int) =
  import scala.math.sqrt
  sqrt(x) + 1.0

Comments and docs

Unison comments are not persisted to the Unison codebase. To save a note to your future self or colleagues, use a string literal or use a Unison Doc expression.

-- A singe line comment.

{-
  A multi-line comment.

  Comments are not saved in the codebase!
-}

myTerm =
  _ = "This text literal will
  be saved to the codebase"
  123 + 456
// A single line comment.

/*
  A multi-line comment.
*/

Documentation

Unison Doc elements are first-class elements in the Unison language. They are dynamically linked to the Unison terms that they describe, can run live examples, and will respond to changes in the codebase.

{{
  This Unison {type Doc} describes something called {myTerm}.

  @signature{myTerm, Map.fromList}

  It can evaluate pure code for dynamic examples:

  ```
  myTerm
  ```

  ```
  Map.fromList [(1, "a"), (2, "b"), (3, myTerm)]
    |> Map.get 3
  ```

  If you change the implementation of `myTerm`,
  this document will change automatically.
}}
myTerm =
  _ = "This text literal will
  be saved to the codebase"
  123 + 456

If a Doc element is created above a term or type, it will automatically share the name of the term or type suffixed with .doc .

/** A Scala Doc for the term below.

With annotations it can automatically update some
information about its inputs and outputs.

It cannot run live snippets of the code it describes.
*/
val myTerm = 123 + 456

Defining and using types

Type declarations

type Floor = Floor Nat Boolean

type Direction = Up | Down

floor2: Floor
floor2 = Floor 2 false
up : Direction
up = Direction.Up

The type keyword introduces a new type. Its data constructors are separated by | on the right of the equals sign.

Think of data constructors as functions which produce values of the given type.

-- the Floor type has one data constructor,
-- a function that looks like this
Floor.Floor : Nat -> Boolean -> Floor
case class Floor(number: Int, isPrivate : Boolean)

sealed trait Direction
case object Up extends Direction
case object Down extends Direction

val floor2 : Floor = Floor(2, false)
val up : Direction = Up

Scala’s type system includes more complex ways of expressing type hierarchies through traits, objects, and classes.

Case classes

Unison record types are similar to case classes in spirit. They’re both used to store named fields, and they automatically provide functions for setting and extracting values from the type by field name.

type Elevator = {
  currentFloor : Floor,
  direction: Direction,
  requests : [Foor]
}

elevator = Elevator (Floor 3 false) Down [(Floor 2 false)]

Elevator.currentFloor elevator

Elevator.direction.set Up elevator
Elevator.currentFloor.modify(cases (Floor n p) -> Floor (n + 1) p) elevator
case class Elevator(
  currentFloor: Floor,
  direction: Direction,
  requests: List[Floor]
)

val elevator = Elevator(Floor(3, false), Down, List(Floor(2, false)))

elevator.currentFloor

elevator.copy(direction = Up)

Case classes don’t have a modify function for their fields

Tagged unions

Say we need to add a type for the panel inside an elevator to better model the requests a user might issue. A user can still request a Floor, but they can also make an emergency call and handle the doors.

type Floor = Floor Nat Boolean

type PanelButton = FloorB Floor | DoorOpen | DoorClose | Emergency

In Unison, if we wanted to keep our Floor type separate from the new PanelButton type (since some business logic may only deal with the Floor and not the panel), we would introduce a wrapper data constructor in our PanelButton type.

sealed trait PanelButton
case object DoorOpen extends PanelButton
case object DoorClose extends PanelButton
case object Emergency extends PanelButton
case class Floor(number: Int, isPrivate : Boolean) extends PanelButton

In Scala, you can add a trait and say that the existing Floor case class is a variant of the PanelButton type. This is a “tagged union,” because each case in the PanelButton trait is tagged with its own type.

Type system comparison

  • Scala supports sub-typing, therefore generic types can express variance relationships, +A -B. Unison does not have sub-typing and its types are invariant.
  • Scala has more options than Unison for type casting and dynamic type inference.
  • Unison does not support typeclasses. Scala has typeclasses via the implicit / given syntax.
  • Unison uses algebraic effects (called Abilities) for effect management.
Feature Unison Scala
Generic types / parametric polymorphism Yes. Generic type parameters are inferred, introduced by lowercase letters. Yes. Generic type parameters must be explicitly declared before use [A] in functions.
Subtyping No. Yes.
Record types Yes. Single data constructor types with named fields. Yes. Case classes
Typeclasses No. Yes. Typeclasses via traits and implicit / given syntax.
GADTs No. Yes. GADT’s via sealed traits and case classes
Higher-kinded types Yes. But in the absence of typeclasses, less common. Yes.
Type aliases No. Yes.

Pattern matching

Data types and literals

type Person = Person Text Nat

toText : Person -> Text
toText person = match person with
  Person n a -> "Name: " ++ n ++ (", Age: " ++ (toText a))

In Unison, the match ... with syntax can be replaced with cases:

toText : Person -> Text
toText = cases
  Person n a -> "Name: " ++ n ++ (", Age: " ++ (toText a))
case class Person(name: String, age: Int)

def toString(person: Person): String = person match {
  case Person(n, a) => s"Name: $n, Age: $a"
}

Pattern guards and @ binding

categorizeNumber : Int -> Text
categorizeNumber n = match n with
  n | n > +0 -> "Positive number"
  0 -> "Zero"
  n | n < +0 "Negative number"
def categorizeNumber(num: Int): String = num match {
  case n if n > 0  => "Positive number"
  case 0           => "Zero"
  case n if n < 0  => "Negative number"
}
type User Text Nat

userTuple : User -> (User, Text)
userTuple user = match user with
  u @ User n a ->  (u, n)
case class User(name: String, age: Int)

def userTuple(user : User): (User, String) = user match {
  case u @ User(n, a) => (u, n)
}

Type checking inside pattern matches

Unison does not support dynamic type checks in pattern matches.

type Box t = Box t

describeBox : Box t -> Text
describeBox b = match b with
  Box _ -> "Cannot get type information from generic type t"

Instead, Unison pattern matches on the data constructors of the type:

type Box t = BoxNat Nat | BoxText Text | BoxOther t

describeBox : Box t -> Text
describeBox b = match b with
  (BoxNat _) -> "Box of a Nat"
  (BoxText _) -> "Box of a Text"
  (BoxOther _) -> "Box of something else"
case class Box[T](value: T)

def describeBox[T](box: Box[T]): String = box match {
  case Box(_: Int)    => "Box of an integer"
  case Box(_: String) => "Box of a string"
  case _              => "Box of something else"
}

List pattern matching

The Unison List type is a finger tree, not a cons list, so it supports fast prepending (+:) and appending ( :+).

List.uncons : [a] -> Optional (a, [a])
List.uncons list = match list with
    x +: xs -> Some (x, xs)
    []      -> None

List.slidingPairs : [a] -> [(a,a)]
List.slidingPairs list =
  loop acc = cases
    [fst, sec] ++ tail ->
      loop (acc :+ (fst, sec)) (sec +: tail)
    _ -> acc
  loop [] list

Pattern match on a list of n elements with the regular list constructor [fst, second]

def uncons[A](list: List[A]): Option[(A, List[A])] = list match {
  case x :: xs => Some((x, xs))
  case Nil     => None
}

def slidingPairs[A](list: List[A]): List[(A, A)] = {
  def loop(remaining: List[A], acc: List[(A, A)]): List[(A, A)] = remaining match {
    case fst :: sec :: tail => loop(sec :: tail, (fst, sec) :: acc)
    case _ => acc.reverse
  }
  loop(list, Nil)
}

Running programs

A runnable “main” function in Unison is a delayed computation (a thunk) which can perform the IO and Exception abilities (think “effects”).

main : '{IO, Exception} ()
main = do
  printLine "Hello world!"

mainWithArgs : '{IO, Exception} ()
mainWithArgs = do
  args : [Text]
  args = IO.getArgs()
  printLine ("program args: " ++ (Text.join ", " args))

pureMain : '{} Nat
pureMain = do
  42
object Main extends App {
    println("Hello world!")
}

object Main {
  def main(args : Array[String]) Unit = {
    println("Hello world!")
  }
}

The REPL

Unison uses watch expressions to interactively evaluate code, instead of a traditional REPL. The Unison Codebase Manager (UCM) watches for changes to .u files and evaluates any lines starting with >.

factorial n = product (range 1 (n + 1))

> factorial 3

UCM will print out:

> factorial 3
  ⧨
  6
scala> val x = 42
x: Int = 42

scala> x + 10
res0: Int = 52

HTTP Service example

Scala http service

import cats.effect._
import org.http4s._
import org.http4s.dsl.io._
import org.http4s.implicits._
import org.http4s.ember.server._
import org.http4s.circe._
import io.circe.syntax._
import io.circe.generic.auto._
import scala.util.Try

case class ConversionResponse(from: String, to: String, input: Double, result: Double)

object UnitConverterService extends IOApp.Simple {

  def convertTemperature(from: String, to: String, value: Double): Either[String, Double] =
    (from, to) match {
      case ("celsius", "fahrenheit")  => Right(value * 9 / 5 + 32)
      case ("fahrenheit", "celsius")  => Right((value - 32) * 5 / 9)
      case ("celsius", "kelvin")      => Right(value + 273.15)
      case ("kelvin", "celsius")      => Right(value - 273.15)
      case ("fahrenheit", "kelvin")   => Right((value - 32) * 5 / 9 + 273.15)
      case ("kelvin", "fahrenheit")   => Right((value - 273.15) * 9 / 5 + 32)
      case _ => Left("Unsupported conversion")
    }

  val convertTempRoute = HttpRoutes.of[IO] {
    case req @ GET -> Root / "convert" / "temperature" =>
      val queryParams = req.params
      val from = queryParams.get("from")
      val to = queryParams.get("to")
      val value = queryParams.get("value").flatMap(v => Try(v.toDouble).toOption)

      (from, to, value) match {
        case (Some(f), Some(t), Some(v)) =>
          convertTemperature(f.toLowerCase, t.toLowerCase, v) match {
            case Right(result) =>
              Ok(ConversionResponse(f, t, v, result).asJson)
            case Left(error)   => BadRequest(error)
          }
        case _ => BadRequest("Missing or invalid query parameters")
      }
  }

  def run: IO[Unit] =
    EmberServerBuilder
      .default[IO]
      .withHttpApp(convertTempRoute.orNotFound)
      .build
      .useForever
}

Unison http service

Instead of describing your dependencies in an sbt file, libraries are managed by with the lib.install command in the UCM.

scratch/main> project.create unit-converter-service
unit-converter-service/main> lib.install @unison/http
unit-converter-service/main> lib.install @unison/routes
unit-converter-service/main> lib.install @unison/json

The libraries will be installed in the lib namespace, viewable with the ls command.

unit-converter-service/main> ls lib

  1. base/                (7481 terms, 182 types)
  2. unison_http_4_0_0/   (24792 terms, 642 types)
  3. unison_json_1_3_5/   (8184 terms, 189 types)
  4. unison_routes_6_3_3/ (127000 terms, 3311 types)
type ConversionResponse = {from: Text, to: Text, input: Float, result: Float}

ConversionResponse.toJson : ConversionResponse -> Json
ConversionResponse.toJson = cases
  ConversionResponse from to input result ->
    Json.object [
      ("from", Json.text from),
      ("to", Json.text to),
      ("input", Json.float input),
      ("result", Json.float result)
    ]

convertTemperature : Text -> Text -> Float -> Either Text Float
convertTemperature from to value =
  match (from, to) with
    ("celsius", "fahrenheit")  -> Right(value * 9.0 / 5.0 + 32.0)
    ("fahrenheit", "celsius")  -> Right((value - 32.0) * 5.0 / 9.0)
    ("celsius", "kelvin")      -> Right(value + 273.15)
    ("kelvin", "celsius")      -> Right(value - 273.15)
    ("fahrenheit", "kelvin")   -> Right((value - 32.0) * 5.0 / 9.0 + 273.15)
    ("kelvin", "fahrenheit")   -> Right((value - 273.15) * 9.0 / 5.0 + 32.0)
    _ -> Left("Unsupported conversion")

convertTempRoute : '{Route} ()
convertTempRoute = do
  Route.noCapture GET (s "convert" Parser./ (s "temperature"))
  queryParams = request.query()
  from = Map.get "from" queryParams |> Optional.flatMap List.head
  to = Map.get "to" queryParams |> Optional.flatMap List.head
  value =
    Map.get "value" queryParams
      |> Optional.flatMap List.head
      |> Optional.flatMap Float.fromText
  match (from, to, value) with
    (Some f, Some t, Some v) ->
      match convertTemperature f t v with
        Right result ->
          resp = ConversionResponse f t v result
          respond.ok.json (ConversionResponse.toJson resp)
        Left error -> respond.badRequest.text error
    _ -> respond.badRequest.text "Missing or invalid query parameters"

unitConversionService : '{IO, Exception}()
unitConversionService = do
  service = convertTempRoute Route.<|> do respond.notFound.text "not found"
  stop = serveSimple service 8080
  printLine "Server running on port 8080. Press <enter> to stop."
  _ = readLine()
  stop()