My first Unison program
The other day, a friend asked me if it was possible to compute the average of N colors. So I wrote a snippet of TypeScript to demonstrate how I’d do it. When I was done, I figured it would be a great way to experiment with Unison.
It’s been a while since I wanted to take Unison for a spin. I think this language is amazing and it bakes in so many interesting design decisions that have huge impact on usability.
The first part of this post is just an overview of the solution I came up with in TypeScript. This is not particularly interesting, that’s why it’s hidden by default, but I will use it as a reference when talking about the Unison reference below, so if you’re not interested in it, I suggest you just go to the Unison directly.
Click here for the TypeScript overview
First, I defined a type for a RGB color:
type ColorRgb = {
red: number;
green: number;
blue: number;
};
Now I need a way to turn the color input, in the hex format, and into the ColorRgb defined above:
function hexParts(value: string): [string, string, string] {
const split = /^([\dA-F]{2})([\dA-F]{2})([\dA-F]{2})$/;
const match = value.match(split);
if (match && match.length === 4 && match[1] && match[2] && match[3]) {
return [match[1], match[2], match[3]];
}
throw Error("Invalid color input.");
}
function hexToColor(value: string) {
const parts = hexParts(value);
const red = parseInt(parts[0], 16);
const green = parseInt(parts[1], 16);
const blue = parseInt(parts[2], 16);
return { red, green, blue };
}
I also needed a function to turn my ColorRGB back to the hex format:
// omitted leftPadWithZero for brevity
function colorToHex(color: ColorRgb) {
const red = leftPadWithZero(2, color.red.toString(16));
const green = leftPadWithZero(2, color.green.toString(16));
const blue = leftPadWithZero(2, color.blue.toString(16));
return `${red}${green}${blue}`.toUpperCase();
}
Then, putting it all together:
// omitted sum for brevity
function colorAverage(colors: Array<string>) {
const rgbs = colors.map((c) => hexToColor(c));
const size = colors.length;
const color = {
red: sum(rgbs.map(({ red }) => red)) / size,
green: sum(rgbs.map(({ green }) => green)) / size,
blue: sum(rgbs.map(({ blue }) => blue)) / size,
};
return colorToHex(color);
}
function main() {
const colors = ["FF0020", "FF00FF", "FF0010"];
console.log(colorAverage(colors));
}
main();
The implementation is pretty simple and straightforward and as you’ll see when we write the Unison version, plenty of things are happening that fly under the radar (like exception being thrown) because the language is less strict.
Unison implementation
We’ll use a similar approach: first write the conversion functions, then a function to do the average, and finally we’ll wire it all together.
I’ll try to go over my process of writing the implementation as I did it the first time around and describe what roadblocks I hit and how I worked them out. Of course, now that I have a working implementation, this is not going to be exactly the same but my experience is so limited that it will be pretty close.
Working in Unison
In TypeScript, you’d write your code in a file.ts, feed that file to the compiler tsc file.ts which will generate a index.js that you can execute through node. Any change that you make to the file.ts content means that you’ll need to re-execute that loop. You can set that up on --watch mode for a better development experience, but it is still one or more program that you have to run every time the input changes.
When you work with Unison, you need the Unison Code Manager or ucm as found on your $PATH. You start it, and it automatically watches for changes happening in scratch.u. It looks like this when you start it:
> ucm -c .
Now starting the Unison Codebase Manager (UCM)...
_____ _
| | |___|_|___ ___ ___
| | | | |_ -| . | |
|_____|_|_|_|___|___|_|_|
👋 Welcome to Unison!
You are running version: release/0.5.50
📚 Read the official docs at https://www.unison-lang.org/docs/ Hint: Type 'projects' to list all your projects, or 'project.create' to start something new.
scratch/main>
At this point, anything that you write and save to the scratch.u file will be picked up by the ucm. Before starting to work on this, I created a color project with project.create color and now my Unison prompts looks more like: color/main>.
Already, at this point, you start to realize that your project management (color) and what seems to be version control (main) appear to be integrated in the ucm. We’ll ignore those for now as I want to focus on the writing code part more than the infrastructure around it.
So let’s open a scratch.u file and work on our implementation.
The implementation
First, let’s define a type for my ColorRgb data type. Looking at the docs, I went with a record type. A container to keep the red, green and blue values together as well as way to retrieve their value.
type ColorRgb = { red: Int, green: Int, blue: Int }
As soon as I hit save, the ucm picks it up and show me the following:
Loading changes detected in ~/workspace/dev/unison/scratch.u.
~ type ColorRgb
~ ColorRgb.blue : ColorRgb -> Int
~ ColorRgb.blue.modify : (Int ->{g} Int) -> ColorRgb ->{g} ColorRgb
~ ColorRgb.blue.set : Int -> ColorRgb -> ColorRgb
~ ColorRgb.green : ColorRgb -> Int
~ ColorRgb.green.modify : (Int ->{g} Int) -> ColorRgb ->{g} ColorRgb
~ ColorRgb.green.set : Int -> ColorRgb -> ColorRgb
~ ColorRgb.red : ColorRgb -> Int
~ ColorRgb.red.modify : (Int ->{g} Int) -> ColorRgb ->{g} ColorRgb
~ ColorRgb.red.set : Int -> ColorRgb -> ColorRgb
~ (modified)
Run `update` to apply these changes to your codebase.
Nice, when defining the type, you also get the machinery to work with it efficiently. The ucm also prompt us to run update to add these types to your codebase, let’s do that. When we do, it just says “Done”, so let’s move on to some code.
As we’ve done with the Typescript version, let’s introduce a function to turn the color in the HEX format into our ColorRgb data type. Naturally, I looked for regular expressions. I searched the docs for regex and eventually landed on this section which invited me to look into Pattern, so I did.
As per the documentation, Patterns are not regular expression, but from glancing at the docs, it sure felt like I could do what I wanted with it, so I got started. I tried to match: ([0-9a-fA-F]){2}, one component of the HEX color, like FF or 0F.
After struggling for a little bit, I finally was able to come up with this, which was compiled by the ucm:
hexColorComponent : Pattern Text
hexColorComponent =
Pattern.oneOf (patterns.digit +| [(charRange ?a ?f), (charRange ?A ?F)])
Before, I got it to work, I had all sort of issues like:
- using
use Pattern digitsnot realizing I was not referring to the right thing, in the docs I foundpatterns.digit - likewise, I did
use Pattern range, again, the doc help me out withcharRange, but this time, it’s notpatterns.charRangebut justcharRange, which I tripped over - function application, and the parenthesis
- here,
oneOftakes aList.Nonempty (Pattern a), and again the syntax to construct it tripped me up. For instance,+|is constructor of forList.Nonemptywhere you pass the first element (head) on the left and the rest (tail, as aList) on the right.
The most interesting part was that I could validate the pattern right way by just evaluating a test expression right under the definition in my scratch.u file. The ucm picks it up and print the result to console, so you can confirm it works right away.
For instance, I added:
> Pattern.run hexColorComponent "test"
> Pattern.run hexColorComponent "FF"
And the ucm printed:
Loading changes detected in ~/workspace/dev/unison/scratch.u.
+ hexColorComponent : Pattern Text
(and 1 unchanged type and 9 unchanged terms)
Run `update` to apply these changes to your codebase.
7 | > Pattern.run hexColorComponent "test"
â§©
None
8 | > Pattern.run hexColorComponent "FF"
â§©
Some ([], "F")
And I realized that I was only matching one character and I was not capturing so I iterated and came up with:
hexColorComponent : Pattern Text
hexColorComponent =
oneChar = Pattern.oneOf (patterns.digit +| [(charRange ?a ?f), (charRange ?A ?F)])
twoChars = Pattern.replicate 2 2 oneChar
Pattern.capture twoChars
And right away the ucm printed:
9 | > Pattern.run hexColorComponent "test"
â§©
None
10 | > Pattern.run hexColorComponent "FF"
â§©
Some (["FF"], "")
I had also read somewhere that you could write tests inline like that so I took a small detour to check it out and this works:
test> color.pattern.hexColorComponent.invalid = check (Optional.isNone (Pattern.run hexColorComponent "test"))
When the ucm sees thats, it says:
9 | test> color.pattern.hexColorComponent.invalid = check (Optional.isNone (Pattern.run hexColorComponent "test"))
✅ Passed Passed
And, just like before, if I run update, the test is added to the codebase.
Then, I was curious to see what would happen if I were to break the implementation, so I turned the two evaluation snippet that I had left into tests, and then broke the implementation by supporting 1 or 2 characters:
hexColorComponent =
oneChar = Pattern.oneOf (patterns.digit +| [(charRange ?a ?f), (charRange ?A ?F)])
twoChars = Pattern.replicate 1 2 oneChar {- broke the implmentation here -}
Pattern.capture twoChars
test> color.pattern.hexColorComponent.invalid = check (Optional.isNone (Pattern.run hexColorComponent "test"))
test> color.pattern.hexColorComponent.valid = check (Optional.isSome (Pattern.run hexColorComponent "FF"))
test> color.pattern.hexColorComponent.tooShort = check (Optional.isNone (Pattern.run hexColorComponent "F"))
As expected, right up on saving the scratch file, the ucm picked it up and told me I had it wrong!
9 | test> color.pattern.hexColorComponent.invalid = check (Optional.isNone (Pattern.run hexColorComponent "test"))
✅ Passed Passed (cached)
10 | test> color.pattern.hexColorComponent.valid = check (Optional.isSome (Pattern.run hexColorComponent "FF"))
✅ Passed Passed
11 | test> color.pattern.hexColorComponent.tooShort = check (Optional.isNone (Pattern.run hexColorComponent "F"))
🚫 FAILED Failed
Nice.
Now, back to the implementation, I need to find the hexColorComponent 3 times to have a complete HEX color. Re-using what I knew already, I reached out to replicate, like so:
hexColor : Pattern Text
hexColor =
threeTimes = Pattern.replicate 3 3 hexColorComponent
Pattern.capture threeTimes
test> color.pattern.hexColor.valid = check (Optional.isSome (Pattern.run hexColor "FFFFFF"))
> Pattern.run hexColor "FFFFFF"
But using the test and the evaluation below, I noticed that it this would return Some (["FFFFFF"], "") instead of what I wanted: Some (["FF", "FF", "FF"], ""), so I went back to the docs, and it turns out I just did not need the Patter.capture and the following works just fine
hexColor : Pattern Text
hexColor =
Pattern.replicate 3 3 hexColorComponent
Then the interesting part, sending the input through the pattern to extract 3 integers that would represent our red, green and blue parts. We’ve already seen that Pattern.run returns a Some if there was a match. The content of which is a List Text for the matches, and a Text for the remainder of the text, bundled together in a tuple.
With that in mind, I reached out to pattern matching to grab just what I wanted and raise otherwise. Pattern matching in Unison is powerful and the syntax caught me off guard. My first attempt looked like this:
ColorRgb.fromHex : Text -> ColorRgb
ColorRgb.fromHex input =
theMatch = Pattern.run hexColor input
match theMatch with
Some([red, green, blue], "") ->
ColorRgb 0 0 0
_ ->
throw Error("Invalid input hex color")
The ucm did not like it! First error was:
The 1st argument to `ColorRgb`
has type: Nat
but I expected: Int
This caught me by surprised, because I had not realized thus far, that Unison has a different type for and Natural numbers and Integers. And the type of 0 is Nat. I went up my scratch file and replaced Ints in my ColorRgb into Nat.
Then the ucm complained about my usage of the Error type, and clearly, that was just my Scala/Python habits getting the best of me. Not only is this type non-existent in my code base, but it’s also invalid syntax.
I reached out to the docs to see how I could throw an error. A quick search led me to Abilities, and I got kind of lucky, because this introduction to the Abilities concept uses try/catch has an example.
From there, I updated my code to:
ColorRgb.fromHex : Text -> {Throw Text} ColorRgb
ColorRgb.fromHex input =
theMatch = Pattern.run hexColor input
match theMatch with
Some([red, green, blue], "") ->
ColorRgb 0 0 0
_ ->
throw "Invalid input hex color"
Now, that I have the three components, I want to convert them from Text to Nat. The text part is a hexadecimal number and I had no idea how to do it with Unison. I remembered from a tutorial that we could use the ucm to look for things, so I hopped in there and typed help. Found, no pun intended, the find command and I typed: find hex.
Unfortunately, it only yielded results for my namespace: color. So I tried help find and found find.all which I tried with find.all hex. It found 70+ results, and while glancing through I found a few functions like fromHex, so I figured that I was probably going to find what I needed with find.all fromHex:
color/main> find.all fromHex
1. lib.base.Bytes.fromHex : Text ->{Exception} Bytes
2. lib.base.Nat.fromHex : Text -> Optional Nat
3. lib.base.Bytes.fromHex.doc : Doc
4. lib.base.Bytes.fromHex.impl : Text -> Either Text Bytes
5. lib.base.Nat.fromHex.doc : Doc
There, Nat.fromHex is what I’m looking for. Right away, I noticed the Optional and I knew I’d have handle it in my implementation. While I had some trouble with the 3 Optionals, this was just me having some issues with the syntax.
In the end, my implementation was the following:
ColorRgb.fromHex : Text -> {Throw Text} ColorRgb
ColorRgb.fromHex input =
theMatch = Pattern.run hexColor input
match theMatch with
Some([red, green, blue], "") ->
redNat = Nat.fromHex red
greenNat = Nat.fromHex green
blueNat = Nat.fromHex blue
match (redNat, greenNat, blueNat) with
(Some redc, Some greenc, Some bluec) -> ColorRgb redc greenc bluec
_ -> throw "Invalid color component"
_ ->
throw "Invalid input hex color"
Naturally, I went to try it out: > ColorRgb.fromHex "FFFFFF" but it failed with the following error:
Loading changes detected in ~/workspace/dev/unison/scratch.u.
The expression in red needs the {Throw Text} ability, but this location does not have access to any abilities.
32 | > ColorRgb.fromHex "FFFFFF"
In hindsight, this is obvious. The ability to throw an error needs to be handled at some point. Looking at the docs, I found that I could use the Either handler to catch the error and put it into a Left instance, so I did:
> toEither do (ColorRgb.fromHex "FFFFFF")
And it printed: Right (ColorRgb 255 255 255). All right.
Now we need to have the inverse function to turn a ColorRgb instance into a HEX color. I came up with:
ColorRgb.toHex : ColorRgb -> Text
ColorRgb.toHex color =
use Text ++
redHex = Natural.toHex (Natural.fromNat (ColorRgb.red color)) |> leftPad 2 "0"
greenHex = Natural.toHex (Natural.fromNat (ColorRgb.green color)) |> leftPad 2 "0"
blueHex = Natural.toHex (Natural.fromNat (ColorRgb.blue color)) |> leftPad 2 "0"
redHex ++ greenHex ++ blueHex
This one was not too bad. I stumbled a bit around the conversion from Nat to hex because I had to go through Natural, but other than that, it was pretty straight forward.
As I was writing out the function I noticed how I naturally used |> to use leftPad and I wondered: since I’m struggling with function application so much and all those parenthesis, maybe it would be easier to just pipe things up like that, so I tried and came up with:
ColorRgb.toHex : ColorRgb -> Text
ColorRgb.toHex color =
use Text ++
natToHex : Nat -> Text
natToHex value = Natural.fromNat value |> Natural.toHex |> leftPad 2 "0"
redHex = natToHex (ColorRgb.red color)
greenHex = natToHex (ColorRgb.green color)
blueHex = natToHex (ColorRgb.blue color)
redHex ++ greenHex ++ blueHex
natToHex value = value |> Natural.fromNat |> Natural.toHex |> leftPad 2 "0".
Now that we have everything to do our conversions, we can write the core of the logic. As per the Typescript implementation, we’ll take in a List Text that we’ll convert to a List ColorRgb and then we’ll compute the average for each component to build the final ColorRgb that we’ll return:
averageColor : List Text -> ColorRgb
averageColor hexColors =
use Nat div
rgbColors = List.map ColorRgb.fromHex hexColors
size = List.size hexColors
colorSum getColor = rgbColors |> List.map getColor |> Nat.sum
colorAvg getColor = div (colorSum getColor) size
red = colorAvg ColorRgb.red
blue = colorAvg ColorRgb.blue
green = colorAvg ColorRgb.green
ColorRgb red blue green
> toEither do averageColor ["FFFFFF", "FF00FF", "FFFF00"]
And it printed:
61 | > toEither do averageColor ["FFFFFF", "FF00FF", "FFFF00"]
â§©
Right (ColorRgb 255 170 170)
That’s it. This code is far from perfect, but it does what the Typescript version does. While writing this post, I re-wrote the implementation and it went so much better. Multiple things clicked:
- using the ucm correctly (to find things and read docs)
- pattern matching was easier than my first time around
- the
pipeoperator and rewriting small functions and combine them (likecolorSumandcolorAvg
Just the experience of the ucm is so different and refreshing. I loved it.
A few things sent me sideways though:
- syntax is very differnt than what I’m used too, but that’s all right
- before just matching on the
OptionalI tried something liketraversebut could not pull it off - found a
notefunction onOptionaland I tried to use it but it was nowhere to be found
Regarding, the last point. When I have a question, I usually search the docs, on the website. From there, it happens that I follow a link to a definition on Unison share. The thing is: sometimes what you’re looking at is not a global thing, but rather something defined only in this project.
For instance, I was trying to throw if the Optional was empty. So navigating on the docs I end up on a page that mentions note. A function with exactly the signature I needed, so I tried to use it for several minutes, but it never worked. I was losing my mind! I tried upgrading the base, it did not work. Eventually I found orThrow and everything was fine.
Today, I can’t find the function on Unison share or the website. But I can still see it from the ucm:
@unison/website/main> view lib.base.Optional.note#mr2j23ch4qfl8k9kl0omj4jmae9rhplgoss2cai79f31as77k2e1n1guck1a9nh2lgutgk9ch6ldnfmfcrsvt59idge8fj5vvblvung
lib.base.Optional.note : e -> Optional a ->{Throw e} a
lib.base.Optional.note e = cases
Optional.None -> throw e
Some a -> a
At the end of the day, it’s a minor issue, and if I had been using a LSP I would probably never have had this issue, but I tried on hard mode! I’ll explore the development experience more on another post, this one is long enough.
Thanks for reading this far.