πŸ—Ί A tour of Unison

This document walks through the basics of using the Unison codebase manager and writing Unison code. We will introduce bits and pieces of the core Unison language and its syntax as we go. The Unison language documentation is a more in-depth resource if you have questions or want to learn more.

If you want to follow along with this document (highly recommended), this guide assumes you've already gone through the steps in the quickstart guide and skimmed through the big idea.

Part 1: πŸ‘‹ to the Unison codebase manager

The Unison Codebase Manager, or UCM for short, is the command line tool that runs the Unison programming language and allows you to interact with the Unison code you've written. Put differently, the UCM is the interface to your Unison codebase.

Its many responsibilities include:

  • Typechecking and compiling new code
  • Organizing, navigating, and finding Unison definitions
  • Storing the state of your codebase
  • Running Unison programs and Unison binaries
  • Publishing and installing Unison libraries

πŸ’‘ Remember: Unison code is not saved as text-based file content. That's why we need a tool that lets us change and run Unison programs.

Running the UCM

By default, running ucm in a directory will interact with any .u suffixed file in the directory where the command was issued while opening the default codebase in your home directory. You'll get a message in the UCM like:

Now starting the Unison Codebase Manager (UCM)...

[…]

Get started:

  πŸ“– Type help to list all commands, or help <cmd> to view help for one command
  🎨 Type ui to open the Codebase UI in your default browser
  πŸ“š Read the official docs at https://www.unison-lang.org/docs/
  🌏 Visit Unison Share at https://share.unison-lang.org to discover libraries
  πŸ‘€ I'm watching for changes to .u files under ~/unisonCode

 .>

What's happening here? This is the Unison Codebase Manager starting up and initializing a fresh codebase. We're used to thinking about our codebase as a bag of text files that's mutated as we make changes to our code, but in Unison the codebase is saved and updated programatically, with the aid of this CLI.

The Unison codebase format has a few key properties:

  • Terms and types are identified by their implementation, not just their name. The codebase stores a hash of the syntax tree of a term or type.
  • It is append-only: all changes to the codebase, including actions like deleting a term or deleting a branch, are appended to a log representing the codebase state.
  • As a result, a Unison codebase has its own notion of versioning and synchronization independent of git, and a robust set of tools has been developed for managing and viewing changes in Unison code over time.

The codebase location the UCM drops you in if you have never used it before is represented by the . in the prompt, .>. We call this "the root" of your codebase, but most of the time your prompt will be prefixed with the name of the most recent "project" you've worked on.

Part 2: πŸ—‚ Creating a project

Rather than creating multiple codebases for each application you're working on, Unison subdivides the codebase into "projects". Unison projects are analogous to source code repositories. They help organize your codebase into applications, libraries, and other work that you may want to collaborate with others on. Projects are further divided into branches for representing independent work streams. We'll introduce the concept of projects by creating one for this tour and establishing some conventions for organizing it.

Inside a project, your code is further organized into Unison namespaces. Namespaces are mappings from human-readable names to their definitions. Names in Unison are things like: math.sqrt, base.Optional.Some, base.Nat, base.Nat.*, ++, or foo. That is one or more segments separated by a ., with the last segment allowed to be an operator name like * or ++.

We often think of these name segments as forming a tree, much like a directory of files. You'll be working with your prompt at the root of your project most of the time in Unison.

In the codebase manager console, create a tour project with the project.create command. This is where you'll be adding code for the remainder of this guide.

.> project.create tour

   πŸŽ‰ I've created the project tour.


   I'll now fetch the latest version of the base Unison library...


   🎨 Type `ui` to explore this project's code in your browser.
   🌏 Discover libraries at https://share.unison-lang.org
   πŸ“– Use `help-topic projects` to learn more about projects.

   Write your first Unison code with UCM:

     1. Open scratch.u.
     2. Write some Unison code and save the file.
     3. In UCM, type `add` to save it to your new project.

   πŸŽ‰ πŸ₯³ Happy coding!

tour/main>

Notice the prompt changes to tour/main>, indicating your current project is now tour and your current branch is /main. When editing Unison code, and interacting with the UCM your UCM commands and code are "scoped" to this project and branch.

When you create a new project, the UCM automatically installs the base standard library for you. It's located in a special namespace called lib.

🧠
Unison looks for project dependencies directly under the project root. The lib directory in our tour project will contain all the dependencies necessary for running the code in the project.

Let's explore the base library that was just downloaded and get used to navigating a Unison codebase next.

Part 3: ⛡️ Navigating a Unison codebase

You can view the terms and types in a namespace with the ls ucm command.

tour/main> ls lib.base.data.List

The output should be a numbered list of definitions and their associated signatures.

tour/main> ls lib.base.data.List

1.   ++                    ([a] -> [a] -> [a])
2.   +:                    (a -> [a] -> [a])
3.   :+                    ([a] -> a -> [a])
4.   Nonempty              (type)
[…]

Because of the nature of the codebase format, we can cache all sorts of interesting information about definitions in the codebase and never have to worry about cache invalidation. For instance, Unison is a statically-typed language and we know the type of all definitions in the codebase, so you can search for definitions in the codebase by their type. Try out the following two find commands (new syntax is explained below):

tour/main> find reverse

1.  lib.base.data.Graph.reverse : Graph v ->{Exception} Graph v
2.  lib.base.data.Graph.tests.reverse : [Result]
3.  lib.base.data.List.Nonempty.reverse : List.Nonempty a -> List.Nonempty a
4.  lib.base.data.List.reverse : [a] -> [a]

The find command here is searching for definitions whose names include reverse. It searches first within our own code in the project, and then in the dependencies in lib.

tour/main> find : [a] -> [a]

 1. lib.base.data.deprecated.Heap.sortDescending : [a] -> [a]
 2. lib.base.data.deprecated.Heap.sort : [a] -> [a]
 3. lib.base.data.List.distinct : [a] -> [a]
 4. lib.base.data.List.sort : [a] -> [a]
 5. lib.base.data.List.dropLast : [a] -> [a]
 6. lib.base.data.List.reverse : [a] -> [a]

tour/main> view 6

  lib.base.data.List.reverse : [a] -> [a]
  lib.base.data.List.reverse as =
     List.foldLeft (acc a -> a +: acc) [] as

Here, we did a type-based search, with find followed by a colon, :, to search for functions of type [a] -> [a]. We got a list of results, and then used the view command to look at the nicely formatted source code of one of these results. Let's introduce some Unison syntax:

  • distributed.lib.base.data.List.reverse : [a] -> [a] is the syntax for giving a type signature to a definition. We pronounce the : symbol as "has type", as in, "reverse has the type [a] -> [a]".
  • [Nat] is the syntax for the type consisting of lists of natural numbers (terms like [0, 1, 2] and [] will have this type), and more generally [Foo] is the type of lists whose elements have some type Foo.
  • A lowercase variable in a type signature is a generic placeholder repsenting some other type. The type signature [a] -> [a] is saying that this is a function which takes a list of elements of some type and returns a list of elements of the same type.
  • List.reverse as takes one argument, called as. The stuff after the = is the body of the function, and here it's a block, which is demarcated by whitespace.
  • acc a -> .. is the syntax for an anonymous function.
  • Function arguments are separated by spaces and function application binds tighter than any operator, so f x y + g p q parses as (f x y) + (g p q). You can always use parentheses to control grouping more explicitly.
✨
Enter the view command without any arguments to open up a fzf search interface for finding definitions in your codebase. It's a quick way to explore the codebase and find definitions by name.

Names are stored separately from definitions so renaming is fast and 100% accurate

The Unison codebase, in its definition for distributed.lib.base.data.List.reverse, doesn't store names for the definitions it depends on (like the distributed.lib.base.data.List.foldLeft function); it references these definitions via their hash. As a result, changing the name(s) associated with a definition is easy.

Let's try this out. distributed.lib.base.data.List.reverse is defined using distributed.lib.base.data.List.foldLeft. Let's rename that to List.foldl temporarily. Try out the following command (you can use tab completion here if you like):

tour/main> move lib.base.data.List.foldLeft lib.base.data.List.foldl

  Done.

tour/main> view lib.base.data.List.reverse

  lib.base.data.List.reverse : [a] -> [a]
  lib.base.data.List.reverse as =
     use base.data.List +:
     base.data.List.foldl (acc a -> a +: acc) [] as

Notice that view shows the foldl name now, so the rename has taken effect. Nice!

Unison isn't doing a bunch of text mutation on your behalf, updating possibly thousands of files, generating a huge textual diff, and also breaking a bunch of downstream library users who are still expecting that definition to be called by the old name. The two names are, to Unison, the same thing.

So rename and move things around as much as you want! Don't worry about picking a perfect name the first time. Naming things is hard enough, renaming them shouldn't be a trial.

The fact that Unison codebases are immutable and append-only means that we can "rewind" our project to an earlier point in time. Use the reflog command to see a log of changes to the project. You should see some help text and a numbered list of hashes.

     Branch      When          Hash          Description
1.   tour/main   39 secs ago   #ijqrr5987q   move tour/main:lib.base.data.List.foldLeft tour/main:lib.bas...
2.   tour/main   18 mins ago   #a1vh3f0sa1   Include latest base library
3.   tour/main   18 mins ago   #sg60bvjo91   Project Created

A reflog keeps track of the history of a particular project, changes to the project like renames, updates, or deletions, form a log of hashes representing the codebase state. When we renamed distributed.lib.base.data.List.foldLeft, conceptually, the "state" of the codebase changed, but the log-based format of the project history means those changes are retrievable.

Let's try to undo the rename action. Use the reset command to pick a prior project state to return to. We'll give it the hash of the project state from just before the move command was issued.

tour/main> reset #a1vh3f0sa1

  Done.

Great! OK, go drink some water, 🚰 and then let's start writing some Unison code!

Part 4: πŸ“ Writing Unison code

Unison's interactive scratch files

The codebase manager listens for changes to any file ending in .u in the current directory. When any such file is saved (which we call a "scratch file"), Unison parses and typechecks that file. Let's try this out.

Keep your ucm terminal running and open up a file, scratch.u (or foo.u, or whatever you like) in your preferred text editor.

Now put the following in your scratch file:

square : Nat -> Nat
square x =
  use Nat *
  x * x

This defines a function called square. It takes an argument called x and it returns x multiplied by itself.

When you save the file, Unison replies:

βœ…

I found and typechecked these definitions in ~/unisoncode/scratch.u. If you do an
`add` or `update` , here's how your codebase would change:

  ⍟ These new definitions are ok to `add`:

    square : Nat -> Nat

Now evaluating any watch expressions (lines starting with `>`)… Ctrl+C cancels.

It typechecked the square function and tells us that square is "ok to add." We'll do that shortly, but first, let's try calling our function right in the scratch.u file, just by starting a line with >:

square : Nat -> Nat
square x =
  use Nat *
  x * x

> square 4

And Unison prints:

6 | > square 4
      ⧩
      16

The > square 4 on line 6 is called a "watch expression". Unison uses these watch expressions instead of having a separate read-eval-print-loop (REPL). The code you are editing can be run interactively as you go, with a full text editor at your disposal, with the same definitions all in scope, without needing to switch to a separate tool.

The use Nat * is a use clause which specifies which "*" operator we want to use. This one is from the Nat namespace in our lib.base standard library. Use clauses mean we can refer to base.Nat as simply Nat and can refer to * without prefixing it Nat.*.

Question: do we really want to reevaluate all watch expressions on every file save? What if they're expensive? Luckily, Unison keeps a cache of results for expressions it evaluates, keyed by the hash of the expression, and you can clear this cache at any time without ill effects. If a result for a hash is in the cache, Unison returns that instead of evaluating the expression again. So you can think of and use your .u scratch files a bit like spreadsheets, which only recompute the minimal amount when dependencies change.

πŸ€“
There's one more ingredient that makes this work effectively, and that's functional programming. When an expression has no side effects, its result is deterministic, and you can cache it as long as you have a good key to use for the cache, like the Unison content-based hash. Unison's type system won't let you do I/O inside one of these watch expressions or anything else that would make the result change from one evaluation to the next.

Let's try out a few more watch expressions:

-- A comment, ignored by Unison

> List.reverse [1,2,3,4]
> 4 + 6
> 5.0 / 2.0
> not true
βœ…

~/unisoncode/scratch.u changed.

Now evaluating any watch expressions (lines starting with
`>`)… Ctrl+C cancels.

  6 | > List.reverse [1,2,3,4]
        ⧩
        [4, 3, 2, 1]

  7 | > 4 + 6
        ⧩
        10

  8 | > 5.0 / 2.0
        ⧩
        2.5

  9 | > not true
        ⧩
        false

Testing your code

Next let's add a test for our square function:

square : Nat -> Nat
square x = x * x

test> square.tests.ex1 =
 verify '(ensureEqual (square 4) 16)

Save the file, and Unison comes back with:

8 | test> square.tests.ex1 = check (square 4 == 16)

βœ… Passed : Proved.

Some syntax notes:

  • The test> prefix tells Unison that what follows is a test watch expression. Note that we're also giving a name to this expression, square.tests.ex1.
  • There's nothing special about the name square.tests.ex1; we could call those bindings anything we wanted. Here we use the convention that tests for a definition foo go in foo.tests.

The distributed.lib.base.test.verify function has the signature distributed.lib.base.test.verify : '{g, Exception, Each, Random} a ->{g} [test.Result]. It handles the Each, Random and Exception abilities. These are common abilities used in testing. The test.ensureEqual function raises an Exception if the two values are not equal, failing the test. In this case, the two values were equal, so the test passes.

A property-based test

Let's test this a bit more thoroughly. square should have the property that square a * square b == square (a * b) for all choices of a and b. The testing library supports writing property-based tests like this. There's some new syntax here, explained afterwards:

use base

square : Nat -> Nat
square x = x * x

use test

test> square.tests.ex1 = verify '(ensureEqual (square 4) 16)

test> square.tests.prop1 =
   verify do
     Each.repeat 100
     a = Random.natIn 0 100000
     b = Random.natIn 0 100000
     ensure (square a * square b == square (a * b))
10 |               verify do

βœ… Passed

This will test our function with a bunch of different inputs using the Each and Random abilities. Each is being used to generate 100 Random numbers, which the the test then verifies the property holds for.

Syntax notes

  • The Unison block, which begins after an =, can have any number of bindings (like a = …) all at the same indentation level, terminated by a single expression (here ensure (square ..)), which is the result of the block.
  • The both the single quote ' syntax and the do keyword are ways of introducing a delayed computation. A delayed computation is one in which the result is not computed right away. The signature for a delayed computation can be thought of as a function with no arguments, returning the eventual result: () -> a.

Part 5: βž•βž– Adding and updating code

The square function and the tests we've written for it are not yet part of the codebase. So far they only exist in our scratch file. Let's add them now. Switch to the UCM and type add. You should get something like:

tour/main> add

  ⍟ I've added these definitions:

    square             : Nat -> Nat
    square.tests.ex1   : [Result]
    square.tests.prop1 : [Result]

Try typing view square or view square.tests.prop1. Notice that Unison inserts precise use statements when rendering your code. A minimal set of use statements is inserted automatically by the code printer upon viewing or editing definitions.

If you type test at the Unison prompt, it will "run" your test suite:

tour/main> test

  Cached test results (`help testcache` to learn more)

  β—‰ square.tests.ex1      : Passed
  β—‰ square.tests.prop1    : Passed

  βœ… 2 test(s) passing

  Tip: Use view square.tests.ex1 to view the source of a test.

But actually, it didn't need to run anything! All the tests had been run previously and cached according to their Unison hash. In a purely functional language like Unison, tests like these are deterministic won't actually rerun until the code they depend on changes!

Moving and renaming terms

When we added square, we were at the tour namespace, so square and its tests are at tour.square. We can also move the terms and namespaces to different locations in our codebase with the move command. move is recursive, so it will move the term and all the things inside the square namespace to the new name.

tour/main> move square mySquare

  Done.

When you're done shuffling some things around, you can use find with no arguments to view all the definitions under the current namespace:

tour/main> find

  1. mySquare : Nat -> Nat
  2. mySquare.tests.ex1 : [Result]
  3. mySquare.tests.prop1 : [Result]

Also notice that we don't need to rerun our tests after this reshuffling.

tour/main> test

  Cached test results (`help testcache` to learn more)

  β—‰ mySquare.tests.ex1       : Passed
  β—‰ mySquare.tests.prop1     : Passed

  βœ… 2 test(s) passing

  Tip:  Use view square.tests.ex1 to view the source of a test.

The tests are still cached because the test cache is keyed by the hash of the test itself, not by what the test happens to be called.

When you're starting out writing some code, sometimes it's nice to put it in a temporary namespace, perhaps called temp or wip. Later, without breaking anything, you can move that namespace or bits and pieces of it elsewhere, using the move command.

Updating existing definitions

Here we'll make a change to the implementation of our mySquare function.

Try entering edit mySquare in the UCM:

tour/main> edit mySquare
  ☝️

  I added these definitions to the top of ~/unisoncode/scratch.u

    mySquare : Nat -> Nat
    mySquare x =
      use Nat *
      x * x

  You can edit them there, then do `update` to replace the definitions currently in this branch.

This copies the pretty-printed definition of mySquare into your scratch file "above the fold." That is, it adds a line starting with --- and puts whatever was already in the file below this line. Unison ignores any file contents below the fold.

Let's edit mySquare and instead define mySquare x (just for fun) as the sum of the first x odd numbers (here's a nice geometric illustration of why this gives the same results):

use base

mySquare : Nat -> Nat
mySquare x =
  sum (map (x -> x * 2 + 1) (range 0 x))

sum : [Nat] -> Nat
sum = foldLeft (+) 0
βœ…

I found and typechecked these definitions in ~/unisoncode/scratch.u. If you do an
''add'' or ''update'' , here's how your codebase would change:

    ⍟ These new definitions are ok to `add`:

      sum : [Nat] -> Nat

    ⍟ These names already exist. You can `update` them to your new definition:

      mySquare : Nat -> Nat

Adding an updated definition to the codebase

Notice the message says that mySquare is ok to update. Let's try that:

tour/main> update

  ⍟ I've added these definitions:

    sum : [Nat] -> Nat

  ⍟ I've updated these names to your new definition:

    mySquare : Nat -> Nat

Only affected tests are rerun on update

If we rerun the tests, the tests won't be cached this time, since one of their dependencies has actually changed:

tour/main> test

  βœ…

    New test results:

  β—‰ mySquare.tests.ex1      : Passed
  β—‰ mySquare.tests.prop1    : Passed

  βœ… 2 test(s) passing

  Tip: Use view mySquare.tests.ex1 to view the source of a test.

The dependency tracking for determining whether a test needs rerunning is 100% accurate and is tracked at the level of individual definitions. You'll only rerun a test if one of the individual definitions it depends on has changed.

Part 6: 🀝 Sharing code and installing Unison libraries

πŸŽ‰
The last you'll need to do to get set up to write Unison code is to sign up for Unison-share! Unison Share is the place to publish and discover Unison libraries. Head to Unison Share and follow the instructions there to link your local codebase for code hosting!

Code is published to Unison's own code hosting solution, Unison Share, using the push command and libraries are installed via the lib.install command. There's no separate tooling needed for managing dependencies or publishing code. It's all built into the UCM.

Congratulations on completing the tour of Unison! You're ready to get writing Unison code. We're excited to see what you build! πŸ₯³