Variables and expressions
aText : Text
aText = "Hello, world!"
aNat : Nat
aNat = 123
anInt : Int
anInt = +123
aBoolean : Boolean
aBoolean = true || false
Unison terms can be defined at the top level of a program. Unlike Python variables, all Unison terms are immutable.
aFunction =
noReassignments = "Initial value"
noReassignments = "🚫 Will not compile, variable is ambiguous."
That means once a variable is introduced, it cannot be changed or reassigned by the program. To update a value, you create a new term that copies the old value and applies the change.
a_string = "Hello, world!"
an_int = 123
a_float = 3.14
a_boolean = True or False
a_reassignment = "initial value"
a_reassignment = "new value"
Python variables are mutable references to values.
Python uses the int
type for both positive and negative whole numbers. In Unison, it's common to use Nat
for non-negative integers and Int
for integers that may be negative. All numeric values using Unison's Int
type must be signed with either a +
or -
prefix.
aNumber : Int
aNumber = "🔤 A string being assigned to an Int won't compile"
Unison is a statically typed language, so every definition has a type at compile time. Type signatures can be inferred, but they are often found above the term for clarity. Unison uses :
to separate the name of the term from its type.
a_str = str("Is this a string?")
type(a_str)
a_str = 123 # Now it's an int!
type(a_str)
In Python 3, type hints can be used to indicate the expected type of a variable, but they are not enforced at runtime. Python is dynamically typed, so a variable can change its type at any time.
Tuples
Unison's tuple type can hold an arbitrary fixed number of values, each of potentially different types. Tuple elements are accessed using at1
, at2
, etc., for the first, second, and subsequent elements.
aTuple : (Text, Nat, Int)
aTuple = ("📘", 2, -100)
first = at1 aTuple
second = at2 aTuple
You cannot unpack a tuple at the top level of a file, outside of a function body. However, inside a function body, for local variable assignments, you can decompose a tuple like this:
foo =
aTuple = ("📘", 2, -100)
(first, second, third) = aTuple
first ++ (Nat.toText second) ++ (Int.toText third)
Both Python and Unison tuples are immutable.
In Python, accessing tuple elements is done with zero-based indexing.
a_tuple = ("📘", 2, -100)
first = a_tuple[0]
second = a_tuple[1]
Unpacking a tuple in Python looks like this:
a_tuple = ("📘", 2, -100)
(first, second, third) = a_tuple
Collections
Unison collections are immutable by default and cannot contain values of different types.
aList : List Nat
aList = [1, 2, 3, 4, 5]
aMap : Map Text Text
aMap = Map.fromList [("🍎", "a"), ("🫐", "100"), ("🍐", "true")]
aSet : Set Int
aSet = Set.fromList [+1, -2, +3, -4, +5]
Unison does not have special syntax for creating Maps
or Sets
, so they are often created from a list of tuples.
In Unison, the following does not mutate the original Map
. Inserting the new key and value creates a new Map
and binds it to a variable:
map1 = Map.fromList [("💙", 1), ("🩷", 12), ("💚", 35)]
map2 = Map.insert "💛" 100 map1
Python collections like lists and dicts are mutable by default. The types of values in a collection can vary.
a_list = [1, 2, 3, 4, 5]
a_dict = {"🍎": "value1", "🫐": 100, "🍐": True}
a_frozen_set = frozenset([1, -2, 3, -4, 5])
Dictionary keys in Python must be hashable. In Unison, there is no restriction on key types in a Map
.
In Python, you might use frozenset
or tuples for immutable collections. This operation on a dictionary will change the original collection:
map_1 = {"💙": 1, "🩷": 12, "💚": 35}
map_1["💛"] = 100
List expressions
Lambdas
This expression doubles each number in the list. Unison uses higher-order functions like List.map
to transform data.
List.map (x -> x * 2) [1, 2, 3]
(x -> x * 2)
is a lambda (an anonymous function) that takes one argument x
and returns x * 2
.
Lambdas in Unison can include local variable assignments and multiple expressions:
List.foldLeft (acc x -> let
double = x * 2
acc + double
) 0 [1, 2, 3]
The let
keyword is a way of starting a code block in Unison.
List comprehensions
In Python, you might use a list comprehension to iterate over elements in a list and create a new one:
[x * 2 for x in [1, 2, 3]]
Here's the same expression using the map
function and a lambda:
list(map(lambda x: x * 2, [1, 2, 3]))
Lambdas in Python can't include local variable definitions or mupltiple expressions, so they are typically used for simple transformations.
Optional values
Unison has a type called Optional
that can either be Some value
or None
. It's is similar to Python's Optional
type from the typing
module.
optionalSome : Optional Text
optionalSome = Some "Hello, world!"
optionalNone : Optional Text
optionalNone = None
foo : Optional Text -> Text
foo bar =
match bar with
Some text -> "Value provided: " ++ text
None -> "No value provided"
The match
expression is used to destructure the Optional
type. You cannot use the value inside an Optional
without pattern matching on it or using a function that unwraps the Optional
type.
from typing import Optional
def foo(bar: Optional[str]) -> str:
if bar is None:
return "No value provided"
return f"Value provided: {bar}!"
In Python, None
is a singleton object representing the absence of a value at runtime. It is similar to Optional.None
in that both address nullability, but it is not tracked by the type system, so after a check for None
, you can use the value directly.
Unit
unit : ()
unit = ()
greet : Text -> {IO, Exception} ()
greet name =
printLine ("Hello " ++ name)
Unison uses the Unit
type, ()
, to represent a function that does not return a meaningful value. It's often used in places where a function is performing an action, such as printing to the console.
None
def greet(name: str) -> None:
print("Hello " + name)
Python uses None
as the type for functions that do not explicitly return something.
Comments
-- This is a single-line comment in Unison
{- This is a
multi-line comment
in Unison -}
Unison comments are not saved to the codebase as part of the definition. Create a string literal if you would like to include a note that is saved with the implementation.
myFunc : Nat -> Nat
myFunc n =
_ = "This expression doubles a number"
n * 2
# This is a single-line comment in Python
"""This is a
multi-line comment
in Python"""
Functions
The basics
Unison and Python are both whitespace-significant languages. Indentation is used to indicate blocks of code, such as function bodies and control flow statements.
hooray : Text -> Text -> Text
hooray a b =
a ++ b ++ "!!!"
- Unison does not have a special keyword for defining a function.
- In the type signature, arguments are separated by arrows, with the last type being the return type of the entire function.
- There are no explicit return statements in Unison. The last expression in a function is the return value.
def hooray(a: str, b: str) -> int:
return a + b + "!!!"
- Python defines functions using the
def
keyword - When type hints are provided, they're located inline with the function arguments.
- The
return
keyword is used to explicitly return a value from a function.
Calling functions
hooray "Hello" "world"
Unison functions are called without parentheses by default. Function arguments are separated by spaces.
hooray "Happy" ((Nat.toText 1) ++ "st birthday")
Parentheses are used to enforce the order of evaluation when necessary.
hooray("Hello", "world")
In Python, functions are called with parentheses and arguments are separated by commas.
Default and variadic arguments
hooray : Text -> Text -> Optional Nat -> Text
hooray a b num =
repeat = Optional.getOrElse 1 num
Text.repeat repeat (a ++ b ++ "!!!")
Unison does not allow default values for function arguments. Instead, you might use the Optional
type to indicate that an argument is optional, and then provide a default value inside the function body.
def hooray(a: str, b: str, repeat: int = 1) -> str:
combined = a + b + "!!!"
return combined * repeat
Python allows default values for function arguments, which can be specified in the function definition.
hoorayMany : [Text] -> Text
hoorayMany texts =
Text.join " " texts ++ "!!!"
Unison does not have built-in support for variadic functions. Instead, you can accept a list of values as an argument.
def hooray_many(*texts: str) -> str:
combined = " ".join(texts) + "!!!"
return combined
Python supports variadic functions using the *args
syntax, which allows you to pass a variable number of arguments to a function.
Control flow
Conditionals
conditional : Nat -> Nat -> Text
conditional a b =
if a > b then
"a is greater than b"
else if a < b then
"a is less than b"
else
"a is equal to b"
Unison uses if
then
and else
for conditional expressions. Both the then
and else
branches must return the same type. Unlike Python, the else
branch is always required.
def conditional(a: int, b: int) -> str:
if a > b:
return "a is greater than b"
elif a < b:
return "a is less than b"
else:
return "a is equal to b"
Python uses if
, elif
, and else
for conditional expressions.
def print_positive(num):
if num > 0:
print("Positive number")
return -100
The else
branch is optional, and the branches can return different types.
Pattern matching
Pattern matching in Unison is a powerful feature that allows you to destructure data types and match against specific patterns. It is similar to switch statements in other languages and it's often used in favor of nested conditional statements.
Unison supports structural pattern matching using the match ... with
syntax.
describeNat : Nat -> Text
describeNat n =
match n with
0 -> "zero"
1 -> "one"
_ -> "many"
The underscore _
is a wildcard that matches any value not explicitly matched by the previous patterns.
All patterns must be exhaustive, meaning that every possible value of the type must be handled. If a pattern is not matched, the code will not compile.
Python has introduced structural pattern matching with the match ... case
syntax.
def describe_num(n: int) -> str:
match n:
case 0:
return "zero"
case 1:
return "one"
case _:
return "many"
Python's match does not enforce exhaustiveness.
Pattern guards
sign : Int -> Text
sign n =
match n with
x | x > +0 -> "positive"
x | x < +0 -> "negative"
_ -> "zero"
In Unison, pattern guards add additional conditions to a case
using the |
symbol:
def sign(n: int) -> str:
match n:
case x if x > 0:
return "positive"
case x if x < 0:
return "negative"
case _:
return "zero"
Python uses if
in a case
clause for the same purpose.
Exception handling
Both Python and Unison use exceptions for error handling and can propagate them up the call stack, but exceptions in Unison are tracked by the type system as an ability.
The key difference is how each language treats effects (operations that go beyond pure computation) like:
- Throwing exceptions
- Printing to the console
- Reading or writing files
- Making network requests
In Unison, the effects a function may perform are part of its type signature. {Exception}
indicates that this function might throw an exception.
safeDivide : Nat -> Nat ->{Exception} Nat
safeDivide a b =
use Int /
if b === 0
then Exception.raise (failure "cannot divide by zero" b)
else a / b
We use special functions called ability handlers to manage effects. Here, we use the catch
function to turn the potential exception into the Either
data type.
catchSafeDivide : Nat -> Nat -> Either Failure Nat
catchSafeDivide a b =
catch do safeDivide a b
Alternatively, we can continue to propagate the exception to the caller by including {Exception}
in the enclosing function's ability set:
callingSafeDivide : '{Exception} Text
callingSafeDivide = do
(Nat.toText (safeDivide 10 5))
At the entry point of a Unison program, only the IO
and Exception
abilities are allowed. All other effects must be handled by:
- Converting them into pure data (e.g.,
Either
orOptional
) - Translating them into
{IO}
or{Exception}
In Python, there is no type signature enforcing that safe_divide
can raise ValueError
. These and other effects are not checked by the interpreter.
def safe_divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
In Python, exceptions can be caught using try
and except
blocks.
def run_with_catch(a, b):
try:
safe_divide(a, b)
except ValueError as e:
print(f"Error: {e}")
return None
Python's try...except blocks are similar to ability handler functions in Unison in that they specify that a code block may be performing an effect and then specify how the runtime should respond. However, try...except
is built into the Python language, whereas Unison handler functions are generalizable and defined by developers.
Data modeling
🌌🔭This topic is vast, but here are some of the key differences between Unison and Python when it comes to modeling data.
Types and functions
Unison does not have classes in the same way that Python does. There are no instance methods, no constructors, and no self
or this
references. Instead, we use data types which structure and contain data and functions which transform them to describe behavior.
This is a type declaration for an Elevator
that tracks the current floor and the top floor of the building:
type Elevator = Elevator Nat Nat
These functions accept the Elevator
type as an argument and return a new Elevator
with updated state.
moveUp : Elevator -> Elevator
moveUp e = match e with
Elevator currentFloor topFloor | currentFloor < topFloor ->
Elevator (currentFloor + 1) topFloor
_ -> e
moveDown : Elevator -> Elevator
moveDown e = match e with
Elevator currentFloor topFloor | currentFloor > 0 ->
Elevator (currentFloor - 1) topFloor
_ -> e
goToFloor : Nat -> Elevator -> Elevator
goToFloor requested e = match e with
Elevator _ topFloor | requested <= topFloor ->
Elevator requested topFloor
_ -> e
Classes and methods
Python uses classes to encapsulate both data and behavior. Methods are defined within classes and operate on the data contained in instances of those classes.
class Elevator:
def __init__(self, current_floor: int = 0, top_floor: int = 10):
self.current_floor = current_floor
self.top_floor = top_floor
def move_up(self):
if self.current_floor < self.top_floor:
self.current_floor += 1
def move_down(self):
if self.current_floor > 0:
self.current_floor -= 1
def go_to_floor(self, floor: int):
if 0 <= floor <= self.top_floor:
self.current_floor = floor
This class defines an Elevator
with methods to move up, move down, and go to a specific floor. The state of the elevator is stored and changed in instance variables.
Function composition
In Unison, we use function composition to chain function calls together and advance the state of a program. The outputs of one function become the inputs to the next.
e0 = Elevator 0 10
e1 = goToFloor 5 e0
e2 = moveUp e1
e3 = moveUp e2
Since the intermediate variables e1
, e2
, and e3
are not needed, we often use the pipe operator |>
to pass the result of one function directly into the next:
Elevator 0 10
|> goToFloor 5
|> moveUp
|> moveDown
Each transformation is applied in sequence, allowing for a clear flow of data through the inputs and outputs of functions.
Dot notation
The dot notation in Python expresses method calls on an object that may mutate the object's internal state.
e = Elevator()
e.go_to_floor(5)
e.move_up()
e.move_down()
No arguments are passed to the methods because the Elevator
instance e
is implicitly passed as a reference to the methods via self
.
Record types
Unison's record types are similar to Python's immutable NamedTuples
. They are used to group related data together with named fields, and provide concise dot-syntax for getting and setting fields.
type Point = {x : Int, y : Int}
This defines a Point
type with two fields, x
and y
, both of type Int
. Defining a record type creates the following accessor and modifier functions:
Point.x : Point -> Int
Point.x.modify : (Int ->{g} Int) -> Point ->{g} Point
Point.x.set : Int -> Point -> Point
Point.y : Point -> Int
Point.y.modify : (Int ->{g} Int) -> Point ->{g} Point
Point.y.set : Int -> Point -> Point
While the following notation looks similar to method calls on an instance of a class, Unison record types are immutable. To "change" a field in a record, you create a new record with the updated value using the generated set
or modify
functions.
p1 = Point +3 -4
x = Point.x p1
p2 = Point.x.set +10 p1
-- p2 is now Point +10 -4, p1 is still Point +3 -4
from typing import NamedTuple
class Point(NamedTuple):
x: int
y: int
p1 = Point(3, -4)
x = p1.x # Accessing the x field
p2 = p1._replace(x=10)
# Creating a new Point with an updated x field
Behavioral abstraction
Parametric polymorphism
In Unison we use generic types to write functions that can operate on any type. They're represented by lowercase letters in type signatures.
printTwice : (a -> Text) -> a -> {IO, Exception} ()
printTwice toText x =
printLine (toText x)
printLine (toText x)
type Duck = Duck
type RoboDuck = RoboDuck
Duck.quack : Duck -> Text
Duck.quack _ = "Quack!"
RoboDuck.quack : RoboDuck -> Text
RoboDuck.quack _ = "Electronic Quack!"
main = do
printTwice Duck.quack Duck
printTwice RoboDuck.quack RoboDuck
In the example above, printTwice
is a function that takes two arguments: a function that converts a value of any type a
to Text
, and a value of type a
. Because a
is used as the type variable for both parameters, the same type must be used in both places when calling printTwice
.
In functional languages, we call the ability to write functions that operate on types independently of their content parametric polymorphism.
Duck-typing
def quack_twice(thing):
print(thing.quack())
print(thing.quack())
class Duck:
def quack(self): return "Quack!"
class RoboDuck:
def quack(self): return "Electronic Quack!"
quack_twice(Duck())
quack_twice(RoboDuck())
Python uses duck-typing to write functions or methods that operate on any object, which means that the expression thing.quack
will succeed if the object a has a quack
method, regardless of what it is.
Algebraic data types
Unison does not support inheritance or subtyping. All types are invariant. But you can use algebraic data types to say that a particular type may be created in several different ways, each of which ultimately has different behavior.
type Duck = AnimalDuck | RoboDuck Text | ToyDuck Text
The type
declaration means that the Duck
type has three data constructors. The AnimalDuck
does not make a special noise, but the other two data constructors (RoboDuck
and ToyDuck
) take a Text
argument representing the specific behavior of that case (a quack prefix in this example). Note that AnimalDuck
, RoboDuck
, and ToyDuck
are functions that are used to create values with the Duck
type when provided with their respective arguments. They are not distinct types themselves.
Duck.toText : Duck -> Text
Duck.toText d = match d with
AnimalDuck -> "Quack!"
RoboDuck prefix -> prefix ++ " Quack!"
ToyDuck prefix -> prefix ++ " Quack!"
The Duck.toText
function uses pattern matching on the data type to determine which kind of duck it received and return the appropriate text. The difference in their behavior is wholly dependent on the values they're constructed with and the functions that inspect their structure.
quacks = do
printTwice Duck.toText Duck.AnimalDuck
printTwice Duck.toText (Duck.RoboDuck "Electronic")
printTwice Duck.toText (Duck.ToyDuck "Squeaky")
Inheritance and subtyping
Python classes can use inheritance to express relationships between objects and share behavior. Let's say we wanted to use subtypes to create different types of ducks that share a common interface for quacking:
class Duck:
def quack(self) -> str:
return "Quack!"
class AnimalDuck(Duck):
pass
class RoboDuck(Duck):
def __init__(self, prefix: str):
self.prefix = prefix
def quack(self) -> str:
return f"{self.prefix} Quack!"
class ToyDuck(Duck):
def __init__(self, prefix: str):
self.prefix = prefix
def quack(self) -> str:
return f"{self.prefix} Quack!"
While two of these subclasses (Roboduck
and ToyDuck
) have an additional instance variable, the other simply inherits its "quack-ing" behavior from its parent.
quack_twice(AnimalDuck())
quack_twice(RoboDuck("Electronic"))
quack_twice(ToyDuck("Squeaky"))
Modules
In Unison, a namespace is a collection of related definitions, which can be functions, types, or other namespaces. Namespaces are introduced by giving terms a dot-separated prefix when they are defined:
database.userModel.getUserName : Nat -> Optional Text
database.userModel.getUserName userId = todo "getUserName"
database.userModel.getUserAge : Nat -> Optional Nat
database.userModel.getUserAge userId = todo "getUserAge"
These two functions are in the database.userModel
namespace.
You can also provide namespace scoping at the top of a file:
namespace database.userModel
getUserName : Nat -> Optional Text
getUserName userId = todo "getUserEmail"
getUserAge : Nat -> Optional Nat
getUserAge userId = todo "getUserAge"
Renaming or moving definitions between namespaces is common, so don't let the long prefixes above intimidate you.
In Python, a file defines a module and a directory can define a package containing many modules (or sub-packages). The module contains the functions, classes, and variables for the program.
# in a file called ./database/usermodel.py
from typing import Optional
def get_user_name(user_id: int) -> Optional[str]:
...
def get_user_age(user_id: int) -> Optional[int]:
...
Unlike Python, Unison does not use the file system to organize or save code so the distinction between a package and module is not relevant.
Imports
Unison differs from Python in that you do not, by default, need to import definitions from other namespaces to use them. If a definition exists in your current project and its name is unambiguous, you can use it directly.
foo.uniqueName : Nat -> Nat
foo.uniqueName n = n + 1
-- works without an import or namespace prefix
bar.baz =
uniqueName 1
If you need to specify where a definition comes from, the use
keyword brings definitions from other namespaces into scope. You can import an entire namespace or specific definitions.
{- Imports everything from the `database.userModel`
namespace for use in the file -}
use database.userModel
{- Use getUserName and getUserAge without the
full namespace prefix -}
api.getUserNameJson : Nat -> Json
api.getUserNameJson userId =
name = getUserName userId
todo "..."
-- Only imports the getUserAge definition
use database.userModel getUserAge
-- Imports both getUserAge and getUserName definitions
use database.userModel getUserAge getUserName
Or you can fully qualify the name of the definition you want to use:
api.getUserAgeJson : Nat -> Json
api.getUserAgeJson userId =
database.userModel.getUserAge userId
todo "..."
In Python, you can use the import
statement to bring in modules or specific definitions from modules.
# imports the entire usermodel module
from database import usermodel
print(usermodel.get_user_name(1))
# imports specific functions from the usermodel module
from database.usermodel import get_user_name, get_user_age
print(get_user_name(1))
Running programs
Unison does not evaluate any code until you explicitly tell it to, so a "main" function is just another function in your codebase until you invoke it with the run
command in the UCM or via the shell.
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
myProject/main> run main
Hello world!
A runnable "main" function in Unison can be any delayed computation (a thunk) which can perform the IO
and Exception
abilities (think "effects").
The Python interpreter starts executing code from the top of the file, so it uses the __name__
and __main__
built-in variables to determine if the script is being run directly or imported as a module.
# In a file called my_script.py
def my_main():
print("Hello world!")
if __name__ == "__main__":
my_main()
$ python my_script.py
Hello world!
The REPL
Unison uses watch expressions to interactively evaluate code, instead of a traditional REPL. Watch expressions let you test and explore code directly in your source files.
The Unison Codebase Manager (UCM) watches for changes to .u
files and evaluates any lines starting with >
in the file as a watch expression. The expressions must be "pure", meaning they cannot perform side effects like IO
or throwing exceptions.
factorial n = Nat.product (range 1 (n + 1))
> factorial 3
Everything in your file and in your current UCM project is in scope. The UCM will print to the console like this:
> factorial 3
⧨
6
Python has a traditional REPL that allows you to enter and evaluate code interactively.
>>> factorial = lambda n: 1 if n == 0 else n * factorial(n - 1)
>>> factorial(3)
6