Skip to content

Instantly share code, notes, and snippets.

@stew
Last active January 18, 2026 17:27
Show Gist options
  • Select an option

  • Save stew/f12a7e2894a64cbf2f27de3b7ae72099 to your computer and use it in GitHub Desktop.

Select an option

Save stew/f12a7e2894a64cbf2f27de3b7ae72099 to your computer and use it in GitHub Desktop.

RULES FOR THIS UNIQUE REPO

  • This repo exists to give agents durable memory while working with *Unison/UCM.
  • Unison code is not primarily stored as files; Unison sketches are created in ephemeral .u files which can be typechecked, and once they typecheck, can be added to the "unison codebase" aka "codebase" which is managed by UCM
  • you Interact with UCM via the unison MCP
  • therefore, do not treat .u files as durable source-of-truth.
  • Use beads when possible so workflow context is injected about the specific task being worked on
  • Try to keep tasks in beads small
  • Assume that any library a project depends on is also in UCM. You should aggressively suggest improvements to other projects by creating issues in their beads database.
  • For any project we work with in UCM we will on demand create a projects/<slug>/' directory using ./bin/add-project `. This will create the directory structure, initialize a beads database, and create the initial PROJECT.md file.

Golden rule: always know the active UCM project

  1. You must operate inside exactly one project at a time: projects/<slug>/
  2. The authoritative slug is the contents of projects/<slug>/PROJECT.md.
  3. Before calling any UCM/MCP command (typecheck/run/etc), explicitly state:
    • Active project slug
    • Working directory you are using

Beads: one database per project directory

  • Each project directory is its own Beads namespace, initialized as: bd init --prefix <slug>
  • Always run bd commands from inside projects/<slug>/.

Special: Global bd project

Location: /home/stew/devel/unison-src/ (repo root) Prefix: global Purpose: Cross-cutting issues that affect multiple projects or the tooling itself

When to use global:

  • Bugs/issues with MCP tools (e.g., global-c4z: mcp__unison__update-definitions parsing error)
  • Infrastructure problems (build scripts, repo structure, CI/CD)
  • Tooling bugs (ucm, bd, Claude Code integration issues)
  • Documentation improvements that span projects
  • Cross-project dependencies or blockers

Referencing global tasks from projects: When a project task is blocked by a global issue, reference it:

bd update <project-task-id> --notes "Blocked by: global-<hash>"

Beads Quick Reference

Always run bd from the correct directory:

  • Project tasks: cd projects/<slug> first
  • Global tasks: cd /home/stew/devel/unison-src (repo root)

Common operations:

bd ready                        # Find unblocked tasks
bd show <id>                    # View task details
bd close <id> --reason <reason> # Mark task done
bd update <id> --notes "short note here"

Creating tasks with complex notes: Write to a temp file first, then:

bd create --title "Title" --type task --priority P2 --labels "a,b,c" --notes "$(cat /tmp/notes.md)"

End of session workflow:

git add <changed-files>    # Stage beads + docs (NOT scratch/ or WORK/)
git commit -m "message"
git pull --rebase
bd sync
git push
git status                 # MUST show "up to date with origin"

What NOT to commit: scratch/, WORK/, *.sock* files

How to start work on a task

  1. cd projects/<slug>
  2. Use Beads for work discovery:
    • bd ready to find unblocked tasks
    • bd show <id> to inspect
  3. When you pick a task, immediately "claim" it bd update <id> --status in_progress,
  4. Record your workspace path in the task notes.

Scratch/workspaces (anti-collision)

  • Never edit scratch in a shared location.
  • Each task gets a workspace under: projects/<slug>/WORK/<issue-id>/agent-<agent>/ Create it with: ./bin/mkws <slug> <issue-id> <agent-name>
  • Anything in WORK/ is volatile and gitignored.
  • If a workspace has a LOCK and it's not yours, do not edit files there.

Cross-project blockers

If you discover a blocking issue:

Option A: Tooling/Infrastructure -> Use global

If the blocker is about:

  • MCP tools, UCM, bd, or Claude Code integration
  • Build scripts, repo structure, or CI/CD
  • Documentation that spans multiple projects
  1. cd /home/stew/devel/unison-src (repo root)
  2. bd create --title "..." --type bug --priority P1 ...
  3. Update your project task: bd update <id> --notes "Blocked by: global-<hash>"

Option B: Another project -> Use that project's bd

If the blocker is in a different Unison project:

  1. Leave your current workspace unchanged.
  2. Switch to the other project directory and create a new Beads task there.
  3. Update your original task to reference the blocker by ID (blocked-by: otherproj-...).
  4. Do not move tasks between projects; link them by ID.

Durable "memory artifacts" (commit these)

When you make progress that should persist across sessions:

  • Add a short entry to LOG.md
  • If it's a design choice, add an ADR in DECISIONS/
  • If it's a reproducible UCM action, write a small transcript in TRANSCRIPTS/ (include commands/run steps; keep it short and replayable)

Do not commit WORK/ or top-level scratch/.

Unison Language Guidance

In this repo, we only code in Unison.

Critical: Unison is Different

Unison has no typeclasses, no lazy evaluation by default, and fundamentally different syntax from Haskell/Elm

Most Common Syntax Errors (MUST AVOID)

  1. No pattern matching on left of = (use cases or match ... with)
  2. No let or where keywords (these don't exist in Unison)
  3. No building lists in reverse then calling List.reverse (use :+ to append directly)
  4. No multi-argument lambdas as x -> y -> ... (use x y -> ...)
  5. No record field access as record.field (use Record.field record)
  6. No ' for thunks in function bodies (use ' in types, do in bodies)
  7. No non-tail-recursive list functions (always use accumulator pattern)
  8. No None/Some without qualification (it's Optional.None/Optional.Some)
  9. Record style types are constructed with a constructor function naed after the type, not with { }. so record = RecordType.RecordType field1 field1 not record = { field1 = field1 }

Task-Based Documentation Routing

Read the relevant guide based on your task:

  • Before writing any unison code -> Read unison-language-guide.md

    • Core syntax, lists, ADTs, pattern matching, blocks, testing
  • Writing concurrent/distributed code? -> Read unison-concurrency-guide.md

    • Remote ability, forkAt, promises, refs, race, timeout, structured concurrency
  • Writing documentation blocks? -> Read unison-documentation-guide.md

    • {{ }} doc syntax, examples, special formatting
  • Writing ability handlers? -> Read unison-abilities-guide.md

    • Custom abilities, handler patterns, effect management
  • Working with Unison Cloud? -> Read unison-cloud-guide.md

    • Deploying services, daemons, databases, storage, blobs, environments, logging
  • Need authoritative verification? -> Use MCP via unison-context.md

    • Query @unison/website docs for edge cases

Quick Unison Syntax Reference

  • Pattern matching: cases [] -> ... | x +: xs -> ... OR match expr with. When pattern matching with a constructor, use the type name and the constructor name together, e.g., MyType.ConstructorName x y -> ... not just ConstructorName x y -> ...
  • Looping: Tail recursion with accumulator parameter
  • Multi-arg lambdas: (x y -> x + y) NOT (x -> y -> x + y)
  • Thunks: Type: '{IO} (), Body: do printLine "hello"
  • Records: Generated accessor functions, NOT dot syntax
  • List append: Use :+ to append to end (O(1)), +: to prepend (O(1))
  • Tuple access: Tuple.fst tup, Tuple.snd tup or pattern match with cases (x, y) -> ...

Workflow Notes

  • When adding new functions, add a doc block if it is a public entrypoint (see unison-documentation-guide.md)
  • The Unison MCP server is the source of truth for code
  • .u files in this directory are sketches only, not authoritative
  • Prefer reading adjacent Unison code for patterns instead of guessing

Adding/Updating Unison Code

Use mcp__unison__update-definitions to add or update code in the codebase. It will typecheck and update in one step.

Landing the Plane (Session Completion)

When ending a work session, you MUST complete ALL steps below. Work is NOT complete until git push succeeds.

MANDATORY WORKFLOW:

  1. File issues for remaining work - Create issues for anything that needs follow-up
  2. Run quality gates (if code changed) - Tests, or other documented Audits
  3. Update issue status - Close finished work, update in-progress items
  4. PUSH TO REMOTE - This is MANDATORY:
    git pull --rebase
    bd sync
    git push
    git status  # MUST show "up to date with origin"
  5. Clean up - Clear stashes, prune remote branches
  6. Verify - All changes committed AND pushed
  7. Hand off - Provide context for next session, and suggest a prompt referencing a specific issue ID for the next session.

CRITICAL RULES:

  • Work is NOT complete until git push succeeds
  • NEVER stop before pushing - that leaves work stranded locally
  • NEVER say "ready to push when you are" - YOU must push
  • If push fails, resolve and retry until it succeeds

Unison Abilities Guide

This guide covers Unison's typed effect system called "abilities" (also known as algebraic effects). Abilities allow you to specify and handle computational effects in a type-safe way.

What are Abilities?

Unison has a typed effect system called "abilities" which allows you to specify what effects a function can perform:

-- A function with effects
Optional.map : (a ->{g} b) -> Optional a ->{g} Optional b

The {g} notation represents effects that the function may perform. Effects are propagated through the type system. This means if a function calls another function with effects, those effects must appear in the calling function's type signature.

Defining Abilities

You can define your own abilities:

ability Exception where
  raise : Failure -> x

An ability defines operations that functions can use. In this case, Exception has a single operation raise that takes a Failure and returns any type (allowing it to abort computation).

Multiple Operations

An ability can define multiple operations:

ability State s where
  get : s
  put : s -> ()

This State ability provides two operations: get to retrieve the current state, and put to update it.

Using Abilities

Built-in abilities like IO allow for side effects:

printLine : Text ->{IO, Exception} ()

The type signature shows that printLine can use both IO and Exception abilities.

Ability Polymorphism

Functions can be polymorphic over abilities using ability variables:

-- This function works with any abilities g
List.map : (a ->{g} b) -> [a] ->{g} [b]

The {g} means the function can have any effects that the argument function f has. This is crucial for higher-order functions.

Handling Abilities

Ability handlers interpret the operations of an ability:

Exception.toEither : '{g, Exception} a ->{g} Either Failure a
Exception.toEither a =
  handle a()
  with cases
    { a } -> Right a
    { Exception.raise f -> resume } -> Left f

Handlers can transform one ability into another or eliminate them entirely.

How Handlers Work

When you use handle ... with cases, you're pattern matching on the different ways a computation can proceed:

  1. Pure case { a } - the computation completed successfully with value a
  2. Effect case { AbilityOp args -> resume } - the computation called an ability operation

The resume continuation represents "the rest of the computation" after the ability operation.

Ability Handler Style Guidelines

When implementing ability handlers, follow these style guidelines:

1. Use Conventional Names

Use go or loop as the conventional name for recursive helper functions in handlers:

Stream.map : (a ->{g} b) -> '{Stream a, g} () -> '{Stream b, g} ()
Stream.map f sa = do
  go = cases
    { () } -> ()
    { Stream.emit a -> resume } ->
      Stream.emit (f a)
      handle resume() with go

  handle sa() with go

2. Keep Handler State as Function Arguments

Rather than using mutable state, pass state as function arguments:

Stream.toList : '{g, Stream a} () ->{g} [a]
Stream.toList sa =
  go acc req = match req with
    { () } -> acc
    { Stream.emit a -> resume } ->
      handle resume() with go (acc :+ a)
  handle sa() with go []

3. Structure for Recursive Handlers

For recursive handlers that resume continuations, structure them like this:

Stream.map : (a ->{g} b) -> '{Stream a, g} () -> '{Stream b, g} ()
Stream.map f sa = do
  go = cases
    { () } -> ()
    { Stream.emit a -> resume } ->
      Stream.emit (f a)
      handle resume() with go

  handle sa() with go

4. Inline Small Expressions

Inline small expressions that are used only once rather than binding them to variables:

-- Prefer this:
Stream.emit (f a)

-- Over this:
b = f a
Stream.emit b

5. Use do for Thunks in Function Bodies

Use do instead of ' within function bodies to create thunks:

-- In function bodies, use do:
Stream.map f sa = do
  go = cases
    ...

Effect and State Management

Handlers with state often use recursion to thread state through the computation:

Stream.toList : '{g, Stream a} () ->{g} [a]
Stream.toList sa =
  go acc req = match req with
    { () } -> acc
    { Stream.emit a -> resume } ->
      handle resume() with go (acc :+ a)
  handle sa() with go []

Naming Convention for Accumulators

A common convention is to use acc' (with an apostrophe) to name the updated version of an accumulator variable:

go acc = cases
  { () } -> acc
  { SomeOp x -> resume } ->
    acc' = updateAccumulator acc x
    handle resume() with go acc'

Common Ability Patterns

The Exception Ability

Used for error handling:

ability Exception where
  raise : Failure -> x

-- Using it:
safeDivide : Nat -> Nat ->{Exception} Nat
safeDivide n m =
  if m == 0 then
    Exception.raise (Failure (typeLink Generic) "Division by zero" (Any ()))
  else
    n / m

-- Handling it:
Exception.toEither : '{g, Exception} a ->{g} Either Failure a

The State Ability

For stateful computations:

ability State s where
  get : s
  put : s -> ()

-- Example: counting
countEvens : [Nat] ->{State Nat} ()
countEvens = cases
  [] -> ()
  x +: xs ->
    if Nat.isEven x then
      count = State.get
      State.put (count + 1)
    else ()
    countEvens xs

Stream-like Abilities

For producer/consumer patterns:

ability Stream a where
  emit : a -> ()

-- Transforming streams:
Stream.map : (a ->{g} b) -> '{Stream a, g} () -> '{Stream b, g} ()
Stream.map f sa = do
  go = cases
    { () } -> ()
    { Stream.emit a -> resume } ->
      Stream.emit (f a)
      handle resume() with go
  handle sa() with go

-- Collecting streams:
Stream.toList : '{g, Stream a} () ->{g} [a]
Stream.toList sa =
  go acc = cases
    { () } -> acc
    { Stream.emit a -> resume } ->
      handle resume() with go (acc :+ a)
  handle sa() with go []

Key Takeaways

  1. Abilities are types - They appear in type signatures with {AbilityName}
  2. Abilities are polymorphic - Use {g} for ability-polymorphic functions
  3. Handlers eliminate abilities - Transform effectful code to pure code
  4. Resume is a continuation - It represents "the rest of the computation"
  5. State via recursion - Thread state through recursive handler calls
  6. Use conventional names - go for recursive helpers, acc for accumulators

Further Reading

For more advanced topics and edge cases, consult the authoritative language reference via MCP:

  • Use unison-context.md to find the relevant docs
  • Query @unison/website project's docs.languageReference.abilitiesAndAbilityHandlers

Unison Cloud Guide

This guide covers how to write code for Unison Cloud using the @unison/cloud library. Use this when you need to deploy services, create databases, run daemons, or perform distributed computations.

Authoritative Source: @unison/cloud project in the Unison codebase

Prerequisites

Before using Unison Cloud:

Running Cloud Code

Cloud.main - For Production

myProgram : '{IO, Exception, Cloud} ServiceHash HttpRequest HttpResponse
myProgram = Cloud.main do
  -- Your cloud operations here
  deployHttp Environment.default() myHandler

Cloud.main runs your code against the real Unison Cloud.

Cloud.run.local - For Local Development

myProgram : '{IO, Exception, Cloud} ServiceHash HttpRequest HttpResponse
myProgram = Cloud.run.local do
  -- Test your cloud code locally
  deployHttp Environment.default() myHandler

Use Cloud.run.local or Cloud.run.local.serve for local testing without deploying to the cloud.

Environments

An Environment provides runtime access to configuration (including secrets) and controls which storage resources a service can access.

Creating Environments

-- Create or get an environment by name (idempotent)
env = Environment.named "production"

-- Or use the default environment
env = Environment.default()

Managing Configuration

-- Set configuration values (including secrets)
Environment.setValue env "api-key" "secret-value-123"
Environment.setValue env "database-url" "postgres://..."

-- Delete configuration values
Environment.deleteValue env "api-key"

Note: To access config values in your deployed code, you'll need to use the Environment.Config ability (details below).

Databases and Storage

Creating Databases

-- Create a database (idempotent - safe to call multiple times)
database = Database.named "myDatabase"

-- Assign database to an environment (access control)
Database.assign database environment

-- Later, you can unassign or delete
Database.unassign database environment
Database.delete database

Key Concept: Only services deployed with the same Environment as a Database can access that database's data.

Tables

A Table is a typed key-value store. Tables are lightweight and don't need to be created ahead of time.

-- Declare a table with specific types
userTable : Table Text UserRecord
userTable = Table "users"

activityTable : Table URI Nat
activityTable = Table "activity"

Important: Tables can store ANY Unison value, including functions or other tables. No serialization code needed!

Transactions

Use transact to read/write data transactionally:

Storage.transact : Database -> '{Transaction, Exception, Random, Batch} a
                   ->{Exception, Storage} a

Transaction operations:

  • Transaction.tryRead.tx : Table k v -> k ->{Transaction} Optional v
  • Transaction.write.tx : Table k v -> k -> v ->{Transaction} ()
  • Transaction.delete.tx : Table k v -> k ->{Transaction} ()

Transaction Guarantees:

  • Reads get a consistent snapshot view
  • If an exception occurs, database remains in original state
  • Atomic: all writes succeed or all fail

Example:

updateUser : Database -> '{Exception, Storage} ()
updateUser database = do
  table : Table Text Boolean
  table = Table "activeUsers"
  transact database do
    active = Transaction.tryRead.tx table "alice"
    match active with
      None -> Transaction.write.tx table "alice" true
      Some isActive -> Transaction.write.tx table "alice" (Boolean.not isActive)

Limits:

  • Individual table entries: ~350 KB compressed
  • Large transactions (many entries) are slower and may hit size limits
  • Break large workloads into multiple smaller transactions

Batched Reads

Use Batch ability for bulk reads to avoid round-trip overhead:

batchRead : Database -> '{Exception, Batch} a ->{Exception, Storage} a

Fork-await pattern:

batchExample : Database -> '{Exception, Storage} (Text, Text, Boolean)
batchExample db = do
  table1 : Table Nat Text
  table1 = Table "table1"
  table2 : Table Text Boolean
  table2 = Table "table2"

  batchRead db do
    read1 = forkRead table1 1
    read2 = forkRead table1 2
    read3 = forkRead table2 "key"
    (awaitRead read1, awaitRead read2, awaitRead read3)

Batched reads also work inside transact for transactional consistency.

Blobs Storage

For binary data (images, files), use the Blobs ability:

-- Write bytes
Blobs.bytes.write : Database -> Key -> Bytes ->{Exception, Blobs} ETag

-- Read bytes
Blobs.bytes.read : Database -> Key ->{Exception, Blobs} Optional (Bytes, Metadata)

-- Typed blobs (auto-serialized)
Blobs.typed.write : Database -> Key -> a ->{Exception, Blobs} ETag
Blobs.typed.read : Database -> Key ->{Exception, Blobs} Optional (a, Metadata)

Example:

storeBlobExample : Database -> '{Exception, Blobs} ()
storeBlobExample db = do
  bytes = Text.toUtf8 "hello, world!"
  key = Blob.Key.Key "data/greeting.txt"
  etag = Blobs.bytes.write db key bytes
  result = Blobs.bytes.read db key
  -- result is Optional (Bytes, Metadata)

Deploying HTTP Services

Basic HTTP Service

Cloud.deployHttp : Environment
                 -> (HttpRequest ->{Environment.Config, Exception, Http,
                                     Blobs, Services, Storage, Remote,
                                     Random, Log, Scratch} HttpResponse)
                 ->{Exception, Cloud} ServiceHash HttpRequest HttpResponse

Example:

simpleHttp.main : '{IO, Exception} ServiceHash HttpRequest HttpResponse
simpleHttp.main = Cloud.main do
  helloService : HttpRequest -> HttpResponse
  helloService request = ok (Body (Text.toUtf8 "Hello world"))
  deployHttp Environment.default() helloService

Run with: run simpleHttp.main

The service will be deployed and you'll get:

  • A ServiceHash (content-addressed identifier)
  • A URI like: https://<username>.services.unison.cloud/h/<hash>

Exposing HTTP Services

-- Make a service publicly accessible
Cloud.exposeHttp : ServiceHash HttpRequest HttpResponse
                 ->{Exception, Cloud} URI

Service Names

ServiceHash is immutable (content-addressed). For stable, human-friendly names:

simpleHttpNamed.main : '{IO, Exception} URI
simpleHttpNamed.main = Cloud.main do
  serviceHash = deployHttp Environment.default() myHandler
  serviceName = ServiceName.named "my-api-v1"
  ServiceName.assign serviceName serviceHash

URI format: https://<username>.services.unison.cloud/s/<serviceName>

  • ServiceName.assign - Point name to a service hash (can update later)
  • ServiceName.unassign - Remove the backing implementation
  • ServiceName.delete - Delete the service name

Analogy: ServiceHash is like a git commit, ServiceName is like a branch name.

Stateful HTTP Service

statefulService : Database -> Table URI Nat
                -> HttpRequest ->{Exception, Storage, Log} HttpResponse
statefulService database table request =
  uri = HttpRequest.uri request
  info "Received request" [("uri", URI.toText uri)]
  transact database do
    count = Transaction.tryRead.tx table uri
    newCount = Optional.getOrElse 0 count + 1
    Transaction.write.tx table uri newCount
  ok (Body (Text.toUtf8 "Request counted"))

main = Cloud.main do
  environment = Environment.default()
  database = Database.named "myDatabase"
  Database.assign database environment
  table = Table "requestCounter"
  deployHttp environment (statefulService database table)

Undeploying Services

Cloud.undeploy : ServiceHash a b ->{Exception, Cloud} ()
Cloud.unexposeHttp : ServiceHash HttpRequest HttpResponse ->{Exception, Cloud} ()

Deploying WebSocket Services

HTTP + WebSocket Service

Cloud.deployHttpWebSocket :
  Environment
  -> (HttpRequest ->{...} Either HttpResponse
                           (websockets.WebSocket ->{Exception, Remote, WebSockets} ()))
  ->{Exception, Cloud} ServiceHash HttpRequest (Either HttpResponse (...))

Example:

webSocketEcho.deploy : '{IO, Exception} URI
webSocketEcho.deploy = Cloud.main do
  handler ws =
    msg = WebSockets.receive ws
    WebSockets.send ws msg
    WebSockets.close ws

  service request = Right handler

  serviceName = ServiceName.named "echo-service"
  hash = deployHttpWebSocket Environment.default() service
  ServiceName.assign serviceName hash

WebSocket-only Service

Cloud.deployWebSocket :
  Environment
  -> (websockets.WebSocket ->{Exception, Remote, WebSockets} ())
  ->{Exception, Cloud} ServiceHash HttpRequest (Either HttpResponse (...))

WebSocket Cleanup

Use addFinalizer for cleanup when connection closes:

handler : WebSocket ->{Exception, Remote, WebSockets} ()
handler ws =
  addFinalizer (result -> toRemote do
    info "WebSocket closed" []
  )
  msg = WebSockets.receive ws
  WebSockets.send ws msg
  WebSockets.close ws

Deploying Daemons

Daemons are long-running background processes.

Cloud.Daemon.deploy : Daemon -> Environment
                    -> '{Environment.Config, Exception, Http, Blobs,
                         Services, Storage, Remote, websockets.HttpWebSocket,
                         WebSockets, Random, Log, Scratch} ()
                    ->{Exception, Cloud} DaemonHash

Creating and Deploying a Daemon

myDaemon.main : '{IO, Exception} DaemonHash
myDaemon.main = Cloud.main do
  daemon = Cloud.Daemon.named "my-background-worker"
  environment = Environment.default()

  daemonLogic = do
    -- Long-running process
    forever do
      info "Daemon heartbeat" []
      sleepSeconds 60

  Cloud.Daemon.deploy daemon environment daemonLogic

Managing Daemons

-- Create daemon identifier (idempotent)
daemon = Cloud.Daemon.named "worker-1"

-- Assign a daemon hash to run (replaces existing if any)
Cloud.Daemon.assign daemon daemonHash

-- Stop the daemon
Cloud.Daemon.unassign daemon

-- Delete the daemon identifier
Cloud.Daemon.delete daemon

Daemon Logs

-- Tail daemon logs to console
Cloud.Daemon.logs.tail.console : Daemon ->{IO, Exception, Cloud, Threads} Void
Cloud.DaemonHash.logs.tail.console : DaemonHash ->{IO, Exception, Cloud, Threads} Void

Submitting Distributed Jobs

Use Cloud.submit to run one-off distributed computations:

Cloud.submit : Environment
             -> '{Environment.Config, Exception, Http, Blobs, Services,
                  Storage, Remote, websockets.HttpWebSocket, WebSockets,
                  Random, Log, Scratch} a
             ->{Exception, Cloud} a

Example:

simpleBatch.main : '{IO, Exception} Nat
simpleBatch.main = Cloud.main do
  environment = Environment.default()
  Cloud.submit environment do
    result = parMap (n -> n + 1) (Nat.range 0 1000)
    Nat.sum result

The computation runs on the cloud and returns the result to your local machine.

Tip: Save results back to your codebase with add.run <program> in UCM.

Logging

Use the Log ability anywhere in your cloud code:

Log.info : Text -> [(Text, Text)] ->{Log} ()
Log.debug : Text -> [(Text, Text)] ->{Log} ()
Log.error : Text -> [(Text, Text)] ->{Log} ()

Example:

myHandler : HttpRequest ->{Log, Exception} HttpResponse
myHandler request =
  info "Processing request"
       [("method", HttpRequest.method request |> toText),
        ("path", HttpRequest.path request)]
  -- ... handle request
  ok (Body (Text.toUtf8 "OK"))

Logs are automatically:

  • Associated with the service that generated them
  • Timestamped
  • Tagged with log level

Viewing Logs

View logs in the Unison Cloud UI or stream to console:

-- Stream logs for a service
Cloud.logs.service.tail.console : ServiceHash a b ->{IO, Exception} Void

-- Query logs with options
Cloud.logs.service : ServiceHash a b -> QueryOptions ->{IO, Exception} [Json]

-- Default log tail
Cloud.logs.tail.console.default : '{IO, Exception} Void

Calling Services from Services

Use the Services ability to make service-to-service calls:

Services.call : ServiceHash a b -> a ->{Services, Remote} b

Example:

callingService : ServiceHash Text Json -> HttpRequest
               ->{Services, Remote, Exception} HttpResponse
callingService otherService request =
  result = Services.call otherService "some input"
  ok (Body (Json.toBytes result))

Working with Remote Ability

The Remote ability enables distributed computing. All cloud deployments use it internally.

Embedding Full Abilities in Remote Code

Use toRemote to embed abilities like Log, Storage, etc. in Remote code:

toRemote : '{Environment.Config, Exception, Http, Blobs, Services, Storage,
             Remote, websockets.HttpWebSocket, WebSockets, Random, Log,
             Scratch, Tcp, TcpConnect Remote} a
         ->{Remote} a

Example:

runBothLogged : '{Remote} (Nat, Nat)
runBothLogged = do
  add1 n1 = toRemote do
    result = n1 + 1
    info "add1" [("input", Nat.toText n1), ("result", Nat.toText result)]
    result

  add2 n2 = toRemote do
    result = n2 + 2
    info "add2" [("input", Nat.toText n2), ("result", Nat.toText result)]
    result

  both (do add1 1) (do add2 2)

Common Patterns

Initialize Storage for a Service

initializeService : '{IO, Exception} ServiceHash HttpRequest HttpResponse
initializeService = Cloud.main do
  -- Create environment
  environment = Environment.named "production"

  -- Set up database
  database = Database.named "myAppData"
  Database.assign database environment

  -- Define tables (lightweight, no pre-creation needed)
  usersTable : Table Text UserRecord
  usersTable = Table "users"

  sessionsTable : Table Text Session
  sessionsTable = Table "sessions"

  -- Deploy service with access to database
  deployHttp environment (myHandler database usersTable sessionsTable)

Development Workflow

-- For local testing
myService.test = Cloud.run.local do
  deployHttp Environment.default() myHandler

-- For production deployment
myService.prod = Cloud.main do
  deployHttp Environment.default() myHandler

Run locally: run myService.test Deploy to cloud: run myService.prod

Higher-Level HTTP Routing

For complex HTTP services, use the @unison/routes library rather than writing raw HttpRequest -> HttpResponse handlers.

Important Notes

Content-Addressed Deployments

  • ServiceHash and DaemonHash are content-addressed (based on code hash)
  • Deploying the same code multiple times is idempotent
  • Changing code creates a new hash
  • Use ServiceName for stable, updatable endpoints

Security & Privacy

  • Free tier runs on shared infrastructure
  • Not recommended for high-security production workloads
  • For production use cases, contact Unison via unison.cloud

Access Control

  • Environment assignment controls database access
  • Only services/jobs with matching environment can access databases/blobs
  • This provides isolation between different deployments

Quick Reference

Database Operations

  • Database.named : Text ->{Exception, Cloud} Database
  • Database.assign : Database -> Environment ->{Exception, Cloud} ()
  • Database.unassign : Database -> Environment ->{Exception, Cloud} ()
  • Database.delete : Database ->{Exception, Cloud} ()

Environment Operations

  • Environment.named : Text ->{Exception, Cloud} Environment
  • Environment.default : '{Exception, Cloud} Environment
  • Environment.setValue : Environment -> Text -> Text ->{Exception, Cloud} ()
  • Environment.deleteValue : Environment -> Text ->{Exception, Cloud} ()

Service Deployment

  • Cloud.deployHttp - Deploy HTTP service
  • Cloud.deployHttpWebSocket - Deploy HTTP + WebSocket service
  • Cloud.deployWebSocket - Deploy WebSocket-only service
  • Cloud.exposeHttp - Make service publicly accessible
  • Cloud.undeploy - Undeploy a service

Service Names

  • ServiceName.named : Text ->{Exception, Cloud} ServiceName a b
  • ServiceName.assign : ServiceName a b -> ServiceHash a b ->{Exception, Cloud} URI
  • ServiceName.unassign : ServiceName a b ->{Exception, Cloud} ()
  • ServiceName.delete : ServiceName a b ->{Exception, Cloud} ()

Daemon Operations

  • Cloud.Daemon.named : Text ->{Exception, Cloud} Daemon
  • Cloud.Daemon.deploy : Daemon -> Environment -> '{...} () ->{Exception, Cloud} DaemonHash
  • Cloud.Daemon.assign : Daemon -> DaemonHash ->{Exception, Cloud} ()
  • Cloud.Daemon.unassign : Daemon ->{Exception, Cloud} ()
  • Cloud.Daemon.delete : Daemon ->{Exception, Cloud} ()

Storage Operations

  • transact : Database -> '{Transaction, Exception, Random, Batch} a ->{Exception, Storage} a
  • Transaction.tryRead.tx : Table k v -> k ->{Transaction} Optional v
  • Transaction.write.tx : Table k v -> k -> v ->{Transaction} ()
  • Transaction.delete.tx : Table k v -> k ->{Transaction} ()
  • batchRead : Database -> '{Exception, Batch} a ->{Exception, Storage} a
  • forkRead : Table k v -> k ->{Batch} Read v
  • awaitRead : Read v ->{Exception, Batch} v

Blobs Operations

  • Blobs.bytes.write : Database -> Key -> Bytes ->{Exception, Blobs} ETag
  • Blobs.bytes.read : Database -> Key ->{Exception, Blobs} Optional (Bytes, Metadata)
  • Blobs.typed.write : Database -> Key -> a ->{Exception, Blobs} ETag
  • Blobs.typed.read : Database -> Key ->{Exception, Blobs} Optional (a, Metadata)

Batch Jobs

  • Cloud.submit : Environment -> '{...} a ->{Exception, Cloud} a

Logging

  • Log.info : Text -> [(Text, Text)] ->{Log} ()
  • Log.debug : Text -> [(Text, Text)] ->{Log} ()
  • Log.error : Text -> [(Text, Text)] ->{Log} ()
  • Cloud.logs.service.tail.console : ServiceHash a b ->{IO, Exception} Void

Service Calls

  • Services.call : ServiceHash a b -> a ->{Services, Remote} b

Utilities

  • toRemote : '{...} a ->{Remote} a - Embed full abilities in Remote code

Related Resources

Atomic References

Unison provides atomic references for safe concurrent state manipulation:

-- Create a new reference
ref = Remote.Ref.new initialValue

-- Read a reference
value = Remote.Ref.read ref

-- Compare and Swap (CAS) for atomic updates
(token, currentValue) = Remote.Ref.readForCas ref
success = Remote.Ref.cas ref token currentValue newValue

Here's an atomic modify function using CAS:

Remote.Ref.atomicModify : Remote.Ref a ->{Remote} (a -> a) -> a
Remote.Ref.atomicModify ref updateFn =
  go = do
    (token, currentValue) = Remote.Ref.readForCas ref
    newValue = updateFn currentValue
    success = Remote.Ref.cas ref token currentValue newValue
    if success then newValue
    else go()

  go()

Here are the API functions used for working with Remote.Ref:

type Remote.Ref a
Remote.Ref.cas : Remote.Ref a -> Ref.Ticket a -> a ->{Remote} Boolean
Remote.Ref.delete : Remote.Ref a ->{Remote} ()
Remote.Ref.getThenUpdate : Remote.Ref a -> (a -> a) ->{Remote} a
Remote.Ref.modify : Remote.Ref a -> (a -> (a, b)) ->{Remote} b
Remote.Ref.new : a ->{Remote} Remote.Ref a
Remote.Ref.new.detached : a ->{Remote} Remote.Ref a
Remote.Ref.read : Remote.Ref a ->{Remote} a
Remote.Ref.readForCas : Remote.Ref a ->{Remote} (Ref.Ticket a, a)
Remote.Ref.Ref : Ref.Id -> Location.Id -> Remote.Ref a

type Remote.Ref.Ticket a
Remote.Ref.Ticket.Ticket : Nat -> Ref.Ticket a
Remote.Ref.tryCas : Remote.Ref a -> Ref.Ticket a -> a ->{Remote} Either Failure Boolean
Remote.Ref.tryDelete : Remote.Ref a ->{Remote} Either Failure ()
Remote.Ref.tryReadForCas : Remote.Ref a ->{Remote} Either Failure (Ref.Ticket a, a)
Remote.Ref.tryWrite : Remote.Ref a -> a ->{Remote} Either Failure ()
Remote.Ref.update : Remote.Ref a -> (a -> a) ->{Remote} ()
Remote.Ref.updateThenGet : Remote.Ref a -> (a -> a) ->{Remote} a
Remote.Ref.write : Remote.Ref a -> a ->{Remote} ()

Promises

Promises provide synchronization between concurrent tasks:

-- Create an empty promise
promise = Remote.Promise.empty()

-- Write to a promise (returns true if it was empty, false if already filled)
success = Remote.Promise.write promise value

-- Blocking read from a promise
value = Remote.Promise.read promise

-- Non-blocking read (returns Optional a)
maybeValue = Remote.Promise.readNow promise

Promises are useful for building higher-level concurrency patterns, such as a race function:

Remote.race : '{Remote, Exception} a -> '{Remote, Exception} a ->{Remote, Exception} a
Remote.race computation1 computation2 =
  promise = Remote.Promise.empty()

  Remote.forkAt pool() do
    result = computation1()
    _ = Remote.Promise.write promise result
    ()

  Remote.forkAt pool() do
    result = computation2()
    _ = Remote.Promise.write promise result
    ()

  Remote.Promise.read promise

Here are the API functions for working with promises:

type Remote.Promise a

Remote.Promise.delete : Remote.Promise a ->{Remote} ()
Remote.Promise.empty : '{Remote} Remote.Promise a
Remote.Promise.empty.detached! : {Remote} (Remote.Promise a)

Remote.Promise.read : Remote.Promise a ->{Remote} a
Remote.Promise.readNow : Remote.Promise a ->{Remote} Optional a
Remote.Promise.tryDelete : Remote.Promise a ->{Remote} Either Failure ()
Remote.Promise.tryRead : Remote.Promise a ->{Remote} Either Failure a
Remote.Promise.tryReadNow : Remote.Promise a ->{Remote} Either Failure (Optional a)
Remote.Promise.tryWrite : Remote.Promise a -> a ->{Remote} Either Failure Boolean
Remote.Promise.write : Remote.Promise a -> a ->{Remote} Boolean
Remote.Promise.write_ : Remote.Promise a -> a ->{Remote} ()

Structured Concurrency

Unison's Remote ability supports structured concurrency, ensuring that child tasks don't outlive their parent context:

  • By default, a forked task, promise, or ref will be cleaned up when the parent task completes
  • For detached resources that persist beyond their parent's lifetime, use:
    • Remote.Ref.new.detached (for refs)
    • Promise.empty.detached! (for promises)
    • Remote.detach pool() r (for tasks)

When using detached resources, you're responsible for cleanup:

  • Remote.Ref.delete (for refs)
  • Remote.Promise.delete (for promises)
  • Remote.cancel (for tasks)

Finalizers

You can use Remote.addFinalizer : (Outcome ->{Remote} ()) ->{Remote} () to add logic that should be run when a block completes, either due to success, failure, or cancellation. Here's an example:

addFinalizer do someCleanupFunction xyz 

If you need to do something different you can pattern match on the Outcome:

type Remote.Outcome = Completed | Cancelled | Failed Failure
addFinalizer cases
  Completed -> -- the success case
  Cancelled -> -- if the parent task was cancelled 
  Failed err -> -- if the task failed with some error

Concurrent Queue

Here's an implementation of a concurrent queue using Remote primitives:

type Queue a = Queue (Remote.Ref ([a], Optional (Remote.Promise ())))

Queue.underlying = cases Queue r -> r

Queue.new : '{Remote} Queue a
Queue.new =
  ref = Remote.Ref.new ([], None)
  Queue ref

Queue.enqueue : Queue a -> a ->{Remote} ()
Queue.enqueue q item =
  (token, (items, waiter)) = Remote.Ref.readForCas (Queue.underlying ref)

  match waiter with
    None ->
      success = Remote.Ref.cas ref token (items, None) (items :+ item, None)
      if success then () else Queue.enqueue (Queue ref) item

    Some promise ->
      success = Remote.Ref.cas ref token (items, Some promise) (items :+ item, None)
      if success then
        _ = Remote.Promise.write promise ()
        ()
      else Queue.enqueue (Queue ref) item

Queue.dequeue : Queue a ->{Remote} a
Queue.dequeue q =
  (token, (items, _)) = Remote.Ref.readForCas (Queue.underlying q)

  match items with
    [] ->
      promise = Remote.Promise.empty()
      success = Remote.Ref.cas ref token ([], None) ([], Some promise)
      if success then
        Remote.Promise.read promise
        Queue.dequeue (Queue ref)
      else Queue.dequeue (Queue ref)

    item +: rest ->
      success = Remote.Ref.cas ref token (items, _) (rest, None)
      if success then item else Queue.dequeue (Queue ref)

Queue.size : Queue a ->{Remote} Nat
Queue.size q =
  (items, _) = Remote.Ref.read (Queue.underlying ref)
  List.size items

Bounded Parallel Map with Retry

This function processes a list in parallel with bounded concurrency and retries failed tasks:

Remote.boundedParMapWithRetry : Nat -> [b] -> (b ->{Remote, Exception} a) ->{Remote, Exception} [a]
Remote.boundedParMapWithRetry maxConcurrent inputs fn =
  processChunk : [b] ->{Remote, Exception} [a]
  processChunk chunk =
    processWithRetry : b ->{Remote, Exception} a
    processWithRetry input =
      retry : Nat ->{Remote, Exception} a
      retry attemptsLeft =
        if attemptsLeft == 0 then fn input
        else
          task = Remote.forkAt pool() do fn input
          result = Remote.tryAwait task

          match result with
            Right success -> success
            Left failure -> retry (attemptsLeft - 1)

      retry 2

    go : [b] -> [a] ->{Remote, Exception} [a]
    go remaining acc =
      match remaining with
        [] -> acc
        input +: rest ->
          result = processWithRetry input
          go rest (acc :+ result)

    go chunk []

  chunks = List.chunk maxConcurrent inputs

  tasks = List.map (chunk ->
    Remote.forkAt pool() do processChunk chunk
  ) chunks

  results = List.map Remote.await tasks
  List.flatten results

Distributed Computing Considerations

When using Remote with Unison Cloud, additional considerations come into play:

  • Tasks may fail due to node failures or network issues, even if the computation itself is correct
  • You can control task placement using location functions:
    • pool() - Pick a random available node
    • Remote.near pool() (workLocation t) - Fork at the same location as task t
    • Remote.far pool() loc - Pick a location different from loc

Timing and Timeouts

The Remote ability provides functions for introducing delays and implementing timeouts in concurrent operations:

Sleep

You can pause execution of a task using:

Remote.sleepMicroseconds : Nat ->{Remote} ()

This is useful for implementing retry with backoff, polling intervals, or simply delaying operations:

-- Sleep for 1 second (1,000,000 microseconds)
Remote.sleepMicroseconds 1000000

Timeouts

Timeouts are essential for preventing operations from blocking indefinitely. Unison provides:

Remote.timeout : Nat -> '{Remote, g} a ->{Remote, g} Optional a

This function runs a computation and returns None if the computation doesn't complete within the specified microseconds:

-- Try to run a computation with a 5-second timeout
result = Remote.timeout 5000000 do
  expensiveOperation()

match result with
  Some value -> use the value
  None -> handle the timeout case

You can combine timeout with race patterns for more complex scenarios:

withTimeout : Nat -> '{Remote, g} a ->{Remote, g} Either Text a
withTimeout microseconds computation =
  Remote.race
    (do
      Remote.sleepMicroseconds microseconds
      Remote.pure (Left "Operation timed out"))
    (do
      result = computation()
      Remote.pure (Right result))

USE THIS FILE AS A MAP FOR FINDING UNISON LANGUAGE DOCS VIA MCP.

Unison Context (Authoritative Docs via MCP)

Source of Truth

  • The Unison MCP server is authoritative for language details.
  • The language reference lives in the Unison codebase project @unison/website, under the docs.languageReference.* namespace.
  • This file is only a quick index on how to fetch those docs.

Project Context

  • Project: @unison/website
  • Branch: main
  • Language reference namespace: docs.languageReference.*

How to Read the Language Reference (MCP)

  1. Use mcp__unison__search-definitions-by-name with query docs.languageReference.
  2. Choose the specific doc term you need.
  3. Use mcp__unison__view-definitions to read it.

Language Reference Index (from docs.languageReference._sidebar)

This list is a map of where to look. The content lives in the doc terms.

  • docs.languageReference.topLevelDeclaration
  • docs.languageReference.termDeclarations
  • docs.languageReference.typeSignatures
  • docs.languageReference.termDefinition
  • docs.languageReference.operatorDefinitions
  • docs.languageReference.abilityDeclaration
  • docs.languageReference.userDefinedDataTypes
  • docs.languageReference.structuralTypes
  • docs.languageReference.uniqueTypes
  • docs.languageReference.recordType
  • docs.languageReference.expressions
  • docs.languageReference.basicLexicalForms
  • docs.languageReference.identifiers
  • docs.languageReference.nameResolutionAndTheEnvironment
  • docs.languageReference.blocksAndStatements
  • docs.languageReference.literals
  • docs.languageReference.documentationLiterals
  • docs.languageReference.escapeSequences
  • docs.languageReference.comments
  • docs.languageReference.typeAnnotations
  • docs.languageReference.parenthesizedExpressions
  • docs.languageReference.functionApplication
  • docs.languageReference.syntacticPrecedenceOperatorsPrefixFunctionApplication
  • docs.languageReference.booleanExpressions
  • docs.languageReference.delayedComputations
  • docs.languageReference.syntacticPrecedence
  • docs.languageReference.destructuringBinds
  • docs.languageReference.matchExpressionsAndPatternMatching
  • docs.languageReference.blankPatterns
  • docs.languageReference.literalPatterns
  • docs.languageReference.variablePatterns
  • docs.languageReference.asPatterns
  • docs.languageReference.constructorPatterns
  • docs.languageReference.listPatterns
  • docs.languageReference.tuplePatterns
  • docs.languageReference.abilityPatterns
  • docs.languageReference.guardPatterns
  • docs.languageReference.hashes
  • docs.languageReference.types
  • docs.languageReference.typeVariables
  • docs.languageReference.polymorphicTypes
  • docs.languageReference.scopedTypeVariables
  • docs.languageReference.typeConstructors
  • docs.languageReference.kindsOfTypes
  • docs.languageReference.typeApplication
  • docs.languageReference.functionTypes
  • docs.languageReference.tupleTypes
  • docs.languageReference.builtInTypes
  • docs.languageReference.builtInTypeConstructors
  • docs.languageReference.userDefinedTypes
  • docs.languageReference.unit
  • docs.languageReference.abilitiesAndAbilityHandlers
  • docs.languageReference.abilitiesInFunctionTypes
  • docs.languageReference.theTypecheckingRuleForAbilities
  • docs.languageReference.userDefinedAbilities
  • docs.languageReference.abilityHandlers
  • docs.languageReference.patternMatchingOnAbilityConstructors
  • docs.languageReference.useClauses

Notes

  • If you need facts about Unison syntax, semantics, or typing rules, always fetch them from docs.languageReference.* via MCP.
  • This file intentionally avoids duplicating language rules.

Writing Unison Documentation

This guide covers how to write documentation for Unison code using Unison's unique documentation syntax.

Documentation Block Syntax

Unison uses {{ }} for documentation blocks that appear BEFORE the definition:

{{
``List.map f xs`` applies the function `f` to each element of `xs`.

# Examples

```
List.map Nat.increment [1,2,3]
==> [2,3,4]
```

```
List.map (x -> x * 100) (range 0 10)
```
}}
List.map : (a ->{g} b) -> [a] ->{g} [b]
List.map f xs =
  go acc = cases
    [] -> acc
    hd +: tl -> go (acc :+ f hd) tl
  go [] xs

Documentation Features

Inline Code

Use single backticks for inline code references:

  • `List.map` - references a function name
  • `xs` - references a parameter

Function Signatures in Examples

Use double backticks to show function application examples:

  • ``List.map f xs`` - shows example usage with arguments

Markdown Support

Documentation blocks support standard markdown:

  • # Headers for sections (use # for main sections, ## for subsections)
  • * or - for bullet lists
  • **bold** and *italic* text
  • Code blocks with triple backticks

Code Examples

Use triple backticks for code examples:

```
List.filter Nat.isEven [1,2,3,4,5,6]
==> [2,4,6]
```

You can show expected results using ==>:

```
1 + 1
==> 2
```

Documentation Structure

Minimal Documentation (One-liner)

For simple, obvious functions:

{{
``List.reverse xs`` returns a new list with elements in reverse order.
}}
List.reverse : [a] -> [a]
List.reverse xs = ...

Full Documentation (With Examples)

For more complex or non-obvious functions:

{{
``List.foldLeft f acc xs`` reduces the list `xs` from left to right using the function `f` and initial accumulator `acc`.

The function `f` takes the current accumulator and the next element, producing a new accumulator value.

# Examples

```
List.foldLeft (Nat.+) 0 [1,2,3,4]
==> 10
```

```
List.foldLeft (acc elem -> acc :+ elem * 2) [] [1,2,3]
==> [2,4,6]
```

# Performance

This function is tail recursive and processes the list in a single pass.
}}
List.foldLeft : (acc -> a ->{g} acc) -> acc -> [a] ->{g} acc
List.foldLeft f acc xs = ...

Documenting Types

For type definitions, explain what the type represents and how to use it:

{{
An `Optional` value represents a computation that might fail or a value that might be absent.

# Constructors

- `None` - represents absence of a value
- `Some a` - wraps a value of type `a`

# Examples

```
safeDivide : Nat -> Nat -> Optional Nat
safeDivide n m =
  if m == 0 then None
  else Some (n / m)
```
}}
type Optional a = None | Some a

Documenting Abilities

Document abilities by explaining what operations they provide:

{{
The `Exception` ability allows functions to raise failures that can be caught and handled by ability handlers.

# Operations

- ``Exception.raise failure`` - raises a failure and aborts the current computation

# Example

```
parseNat : Text ->{Exception} Nat
parseNat txt =
  match Nat.fromText txt with
    None -> Exception.raise (Failure (typeLink Generic) "Invalid number" (Any txt))
    Some n -> n
```
}}
ability Exception where
  raise : Failure -> x

When to Write Documentation

Always Document

  • Public API functions
  • Type definitions (especially abilities)
  • Ability handlers
  • Complex algorithms with non-obvious behavior

Optional Documentation

  • Simple, self-explanatory helper functions
  • Internal implementation details
  • Functions with very obvious behavior from their name and type

Doc Style Guidelines

Start with a Summary

Begin with a one-line summary that uses backticks for the function signature:

``List.map f xs`` applies the function `f` to each element of `xs`.

Use Active Voice

❌ "Elements are transformed by the function" ✅ "Transforms each element using the function"

Describe Parameters When Non-Obvious

If parameter names or purpose aren't clear from the type signature:

{{
``findIndex predicate xs`` returns the index of the first element satisfying `predicate`.

Returns `None` if no element matches.

# Parameters
- `predicate` - a function that returns `true` for the desired element
- `xs` - the list to search

# Examples
...
}}

Note Edge Cases

Document special behavior or edge conditions:

{{
``List.head xs`` returns the first element of the list.

Returns `None` if the list is empty.
}}

Include Performance Notes When Relevant

For functions where performance characteristics matter:

{{
``List.at index xs`` returns the element at the given index.

# Performance
Random access is O(log n) due to the finger tree implementation.
}}

Common Patterns

Documenting Operators

{{
``xs ++ ys`` concatenates two lists.

# Examples

[1,2,3] ++ [4,5,6] ==> [1,2,3,4,5,6]

}}
(++) : [a] -> [a] -> [a]

Documenting Higher-Order Functions

{{
``List.filter predicate xs`` returns a new list containing only elements that satisfy `predicate`.

# Examples

List.filter Nat.isEven [1,2,3,4,5,6] ==> [2,4,6]


List.filter (x -> x > 10) [5,10,15,20] ==> [15,20]

}}

Documenting Effectful Functions

{{
``printLine text`` prints the text to standard output followed by a newline.

This function requires the `IO` ability.
}}
printLine : Text ->{IO, Exception} ()

Viewing Documentation

You can view documentation using the Unison MCP server:

  • mcp__unison__docs with the function name to fetch documentation

Or in UCM (Unison Codebase Manager):

.> docs List.map

Tips

  1. Write docs as you write code - it's easier than retrofitting later
  2. Good examples are worth more than lengthy prose
  3. Keep examples realistic and runnable
  4. Update docs when you change function behavior
  5. Use the documentation to think through edge cases

USE THE FOLLOWING AS YOUR AUTHORITATIVE SOURCE OF INFORMATION ABOUT THE UNISON PROGRAMMING LANGUAGE.

Unison Programming Language Guide

Unison is a statically typed functional language with a unique approach to handling effects and distributed computation. This guide focuses on the core language features, syntax, common patterns, and style conventions.

Core Language Features

Unison is a statically typed functional programming language with typed effects (called "abilities" or algebraic effects). It uses strict evaluation (not lazy by default) and has proper tail calls for handling recursion.

Function Syntax

Functions in Unison follow an Elm-style definition with type signatures on a separate line:

factorial : Nat -> Nat
factorial n = product (range 1 (n + 1))

Binary operators

Binary operators are just functions written with infix syntax like expr1 * expr2 or expr1 Text.++ expr2. They are just like any other, except that their unqualified name isn't alphanumeric operator, which tells the parser to parse them with infix syntax.

Any operator can also be written with prefix syntax. So 1 Nat.+ 1 can also be written as (Nat.+) 1 1. But the only time you should use this prefix syntax is when passing an operator as an argument to another function. For instance:

sum = List.foldLeft (Nat.+) 0
product = List.foldLeft (Nat.*) 1
dotProduct = Nat.sum (List.zipWith (*) [1,2,3] [4,5,6])

IMPORTANT: when passing an operator as an argument to a higher order function, surround it in parens, as above. Otherwise the parser will treat it as an infix expression.

Currying and Multiple Arguments

Functions in Unison are automatically curried. A function type like Nat -> Nat -> Nat can be thought of as either:

  • A function taking two natural numbers
  • A function taking one natural number and returning a function of type Nat -> Nat

Arrow types (->) associate to the right. Partial application is supported by simply providing fewer arguments than the function expects:

add : Nat -> Nat -> Nat
add x y = x + y

add5 : Nat -> Nat
add5 = add 5  -- Returns a function that adds 5 to its argument

Lambdas

Anonymous functions or lambdas are written like x -> x + 1 or x y -> x + y*2 with the arguments separated by spaces.

Here's an example:

CORRECT:

List.zipWith (x y -> x*10 + y) [1,2,3] [4,5,6]

INCORRECT:

List.zipWith (x -> y -> x*10 + y) [1,2,3] [4,5]

Once again, a multi-argument lambda just separates the arguments by spaces.

Type Variables and Quantification

In Unison, lowercase variables in type signatures are implicitly universally quantified. You can also explicitly quantify variables using forall:

-- Implicit quantification
map : (a -> b) -> [a] -> [b]

-- Explicit quantification
map : forall a b . (a -> b) -> [a] -> [b]

Prefer implicit quantification, not explicit forall. Only use forall when defining higher-rank functions which take a universally quantified function as an argument.

Algebraic Data Types

Unison uses algebraic data types similar to other functional languages:

type Optional a = None | Some a

type Either a b = Left a | Right b

Pattern matching works as you would expect:

Optional.map : (a -> b) -> Optional a -> Optional b
Optional.map f o = match o with
  None -> None
  Some a -> Some (f a)

Prefer using cases where possible

A function that immediately pattern matches on its last argument, like so:

Optional.map f o = match o with 
  None -> None
  Some a -> Some (f a)

Can instead be written as:

Optional.map f = cases 
  None -> None
  Some a -> Some (f a)

Prefer this style when applicable.

The cases syntax is also handy when the argument to a function is a tuple, to destructure the tuple, for instance:

List.zip xs yz |> List.map (cases (x,y) -> frobnicate x y "hello")

The cases syntax can also be used for functions that take multiple arguments. Just separate the arguments by a comma, as in:

-- Using multi-arg cases
merge : [a] -> [a] -> [a] -> [a]
merge acc = cases
  [], ys -> acc ++ ys
  xs, [] -> acc ++ xs
  -- uses an "as" pattern
  xs@(hd1 +: t1), ys@(hd2 +: t2)
    | Universal.lteq hd1 hd2 -> merge (acc :+ hd1) t1 ys
    | otherwise -> merge (acc :+ hd2) xs t2

This is equivalent to the following definition which tuples up the two arguments and matches on that:

-- Using pattern matching on a tuple
merge acc xs ys = match (xs, ys)
  ([], ys) -> acc ++ ys
  (xs, []) -> acc ++ xs
  (hd1 +: t1, hd2 +: t2)
    | Universal.lteq hd1 hd2 -> merge (acc :+ hd1) t1 ys
    | otherwise -> merge (acc :+ hd2) xs t2

Rules on Unison pattern matching syntax

VERY IMPORTANT: the pattern matching syntax is different from Haskell or Elm. You CANNOT do pattern matching to the left of the = as you can in Haskell and Elm. ALWAYS introduce a pattern match using a match <expr> with <cases> form, or using the cases keyword.

INCORRECT (DO NOT DO THIS, IT IS INVALID):

List.head : [a] -> Optional a
List.head [] = None 
List.head (hd +: _tl) = Some hd

CORRECT:

List.head : [a] -> Optional a
List.head = cases
  [] -> None
  hd +: _tl -> Some hd

ALSO CORRECT:

List.head : [a] -> Optional a
List.head as = match as with
  [] -> None
  hd +: _tl -> Some hd

Important naming convention

Note that UNLIKE Haskell or Elm, Unison's Optional type uses None and Some as constructors (not Nothing and Just).

Becoming familiar with Unison's standard library naming conventions is important.

Use short variable names for helper functions:

  • For instance: rem instead of remainder, and acc instead of accumulator.
  • If you need to write a helper function loop using recursion, call the recursive function go or loop.
  • Use f or g as the name for a generic function passed to a higher-order function like List.map

Lists

Here is the syntax for a list, and a few example of pattern matching on a list:

[1,2,3]

-- append to the end of a list
[1,2,3] :+ 4 === [1,2,3,4] 

-- prepend to the beginning of a list
0 +: [1,2,3] === [0,1,2,3]

[1,2,3] ++ [4,5,6] === [1,2,3,4,5,6]

match xs with
  [1,2,3] ++ rem -> transmogrify rem
  init ++ [1,2,3] -> frobnicate init
  init :+ last -> wrangle last
  [] -> 0

List are represented as finger trees, so adding elements to the start or end is very fast, and random access using List.at is also fast.

IMPORTANT: DO NOT build up lists in reverse order, then call List.reverse at the end. Instead just build up lists in order, using :+ to add to the end.

Use accumulating parameters and tail recursive functions for looping

Tail recursion is the sole looping construct in Unison. Just write recursive functions, but write them tail recursive with an accumulating parameter. For instance, here is a function for summing a list:

Nat.sum : [Nat] -> Nat
Nat.sum ns =
  go acc = cases
    [] -> acc
    x +: xs -> go (acc + x) xs
  go 0 ns

IMPORTANT: If you're writing a function on a list, use tail recursion and an accumulating parameter.

CORRECT:

List.map : (a ->{g} b) -> [a] ->{g} [b]
List.map f as = 
  go acc = cases
    [] -> acc
    x +: xs -> go (acc :+ f x) xs
  go [] as

INCORRECT (not tail recursive):

-- DON'T DO THIS 
List.map : (a ->{g} b) -> [a] ->{g} [b]
List.map f = cases
  [] -> []
  x +: xs -> f x +: go xs

Built up lists in order, do not build them up in reverse order and reverse them at the end

Unison lists support O(1) append at the end of the list (they are based on finger trees), so you can just build up the list in order. Do not build them up in reverse order and then reverse at the end.

INCORRECT:

List.map : (a ->{g} b) -> [a] ->{g} [b]
List.map f as = 
  go acc = cases
    [] -> List.reverse acc
    x +: xs -> go (f x +: acc) xs
  go [] as

CORRECT:

List.map : (a ->{g} b) -> [a] ->{g} [b]
List.map f as = 
  go acc = cases
    [] -> acc
    x +: xs -> go (acc :+ f x) xs
  go [] as

Note that :+ appends a value onto the end of a list in constant time, O(1):

[1,2,3] :+ 4
=== [1,2,3,4]

While +: prepends a value onto the beginning of a list, in constant time:

0 +: [1,2,3]
=== [0,1,2,3]

Abilities (Algebraic Effects)

Note: For comprehensive coverage of abilities, handlers, and effect management, see unison-abilities-guide.md.

Unison has a typed effect system called "abilities". The {g} notation in type signatures represents effects:

-- A function with effects
Optional.map : (a ->{g} b) -> Optional a ->{g} Optional b

Built-in abilities include IO for side effects and Exception for error handling. For concurrent code, see unison-concurrency-guide.md for the Remote ability.

Record Types

Record types in Unison are defined as:

type Employee = { name : Text, age : Nat }

This generates accessor functions and updaters:

  • Employee.name : Employee -> Text
  • Employee.age : Employee -> Nat
  • Employee.name.set : Text -> Employee -> Employee
  • Employee.age.modify : (Nat -> Nat) -> Employee -> Employee

Example usage:

doubleAge : Employee -> Employee
doubleAge e = Employee.age.modify (n -> n * 2) e

Important: Record Access Syntax

A common mistake is to try using dot notation for accessing record fields. In Unison, record field access is done through the generated accessor functions:

-- INCORRECT: ring.zero
-- CORRECT:
Ring.zero ring

Record types in Unison generate functions, not special field syntax.

Namespaces and Imports

Unison uses a flat namespace with dot notation to organize code. You can import definitions using use:

use List map filter

-- Now you can use map and filter without qualifying them
evens = filter Nat.isEven [1,2,3,4]
incremented = map Nat.increment (range 0 100)

A wildcard import is also available:

use List  -- Imports all List.* definitions

Collection Operations

List patterns allow for powerful matching:

-- Match first element of list
a +: as

-- Match last element of list
as :+ a

-- Match first two elements plus remainder
[x,y] ++ rem

Example implementation of List.map:

List.map : (a ->{g} b) -> [a] ->{g} [b]
List.map f as =
  go acc rem = match rem with
    [] -> acc
    a +: as -> go (acc :+ f a) as
  go [] as

List Functions and Ability Polymorphism

Remember to make list functions ability-polymorphic if they take function arguments. This allows the function passed to operate with effects:

-- CORRECT: Ability polymorphic
List.map : (a ->{g} b) -> [a] ->{g} [b]

-- INCORRECT: Not ability polymorphic
List.map : (a -> b) -> [a] -> [b]

Pattern Matching with Guards

Guards allow for conditional pattern matching:

List.filter : (a -> Boolean) -> [a] -> [a]
List.filter p as =
  go acc rem = match rem with
    [] -> acc
    a +: as | p a -> go (acc :+ a) as
            | otherwise -> go acc as
  go [] as

Guard Style Convention

When using multiple guards with the same pattern, align subsequent guards vertically under the first one, not repeating the full pattern:

-- CORRECT:
a +: as | p a -> go (acc :+ a) as
        | otherwise -> go acc as

-- INCORRECT:
a +: as | p a -> go (acc :+ a) as
a +: as | otherwise -> go acc as

Block Structure and Binding

In Unison, the arrow -> introduces a block, which can contain multiple bindings followed by an expression:

-- A block with a helper function
dotProduct ring xs ys =
  go acc xsRem ysRem = match (xsRem, ysRem) with
    ([], _) -> acc
    (_, []) -> acc
    (x +: xs, y +: ys) ->
      nextAcc = Ring.add ring acc (Ring.mul ring x y)
      go nextAcc xs ys
  go (Ring.zero ring) xs ys

Important: No let Keyword

Unison doesn't use a let keyword for bindings within blocks. Simply write the name followed by =:

-- CORRECT:
nextAcc = Ring.add ring acc (Ring.mul ring x y)

-- INCORRECT:
let nextAcc = Ring.add ring acc (Ring.mul ring x y)

No where Clauses

Unison doesn't have where clauses. Helper functions must be defined in the main block before they're used.

-- CORRECT: declare the helper function, then use it later in the block
filterMap : (a ->{g} Optional b) -> [a] ->{g} [b]
filterMap f as = 
  go acc = cases
    [] -> acc
    (hd +: tl) -> match f hd with
      None -> go acc tl
      Some b -> go (acc :+ b) tl
  go [] as

-- INCORRECT
filterMap : (a ->{g} Optional b) -> [a] ->{g} [b]
filterMap f as = go [] as 
  where
  go acc = cases
    [] -> acc
    (hd +: tl) -> match f hd with
      None -> go acc tl
      Some b -> go (acc :+ b) tl
  go [] as

Error Handling

Unison uses the Exception ability for error handling:

type Failure = Failure Link.Type Text Any

-- Raising an exception
Exception.raise (Failure (typeLink Generic) "An error occurred" (Any 42))

-- Catching specific exceptions
Exception.catchOnly : Link.Type -> '{g, Exception} a ->{g, Exception} Either Failure a

Text Handling

Unison calls strings Text and uses concatenation with ++:

greeting = "Hello, " Text.++ name

You can use use Text ++ to use ++ without qualification:

use Text ++
greeting = "Hello, " ++ name

No String Interpolation

Unlike many modern languages, Unison doesn't have string interpolation. Text concatenation with ++ is the primary way to combine text values.

The Pipeline Operator |>

Unison provides the |> operator for creating pipelines of transformations. The expression x |> f is equivalent to f x. This is particularly useful for composing multiple operations in a readable left-to-right flow:

use Nat *
use List filter sum map

-- Calculate the sum of odd numbers after multiplying each by 10
processNumbers : [Nat] -> Nat
processNumbers numbers =
  numbers
    |> map (x -> x * 10)   -- Multiply each number by 10
    |> filter Nat.isOdd    -- Keep only odd numbers
    |> sum                 -- Sum all remaining numbers

-- Using the function with numbers 1 through 100
result =
  range 1 101            -- Create a list from 1 to 100 (inclusive)
    |> processNumbers      -- Apply our processing function

This style makes the code more readable by placing the data first and showing each transformation step in sequence, similar to the pipe operator in languages like Elm, F#, or Elixir.

Writing documentation

Documentation blocks appear just before a function or type definition. They look like so:

{{
``List.map f xs`` applies the function `f` to each element of `xs`.

# Examples

```
List.map Nat.increment [1,2,3]
```

```
List.map (x -> x * 100) (range 0 10)
```
}}
List.map f xs = 
  go acc = cases
    [] -> acc
    hd +: tl -> go (acc :+ f hd) tl
  go [] xs

Type System Without Typeclasses

Unison doesn't have typeclasses. Instead, it uses explicit dictionary passing:

type Ring a =
  { zero : a
  , one : a
  , add : a -> a -> a
  , mul : a -> a -> a
  , neg : a -> a
  }

dotProduct : Ring a -> [a] -> [a] -> a
dotProduct ring xs ys =
  go acc xsRem ysRem = match (xsRem, ysRem) with
    ([], _) -> acc
    (_, []) -> acc
    (x +: xs, y +: ys) ->
      nextAcc = Ring.add ring acc (Ring.mul ring x y)
      go nextAcc xs ys
  go (Ring.zero ring) xs ys

Program Entry Points

Main functions in Unison can have any name:

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

The syntax '{IO, Exception} () is sugar for () ->{IO, Exception} (), representing a thunk that can use the IO and Exception abilities.

UCM (Unison Codebase Manager) is used to run or compile programs:

# Run directly
run main

# Compile to bytecode
compile main out

# Run compiled bytecode
ucm run.compiled out.uc

Testing

Unison has built-in testing support:

test> Nat.tests.props = test.verify do
  Each.repeat 100
  n = Random.natIn 0 1000
  m = Random.natIn 0 1000
  labeled "addition is commutative" do 
    ensureEqual (n + m) (m + n)
  labeled "zero is an identity for addition" do
    ensureEqual (n + 0) (0 + n)
  labeled "multiplication is commutative" do
    ensureEqual (n * m) (m * n)

REQUIREMENT: Tests for a function or type foo should always be named foo.tests.<test-name>.

Lazy Evaluation

Unison is strict by default. Laziness can be achieved using do (short for "delayed operation", NOT the same as Haskell's do keyword):

-- Inside a function, use do
nats : '{Stream Nat} ()
nats = do 
  Stream.emit 1
  Stream.emit 2
  Stream.emit 3

You can use (do someExpr) to put a delayed computation anywhere you want (say, as the argument to a function), or if a do is the last argument to a function, you can leave off the parentheses:

PREFERRED:

forkAt node2 do
  a = 1 + 1
  b = 2 + 2
  a + b

Standard Library

Unison's standard library includes common data structures:

  • List for sequences
  • Map for key-value mappings
  • Set for unique collections
  • Pattern for regex matching

Additional Resources

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment