In the era of Corona, I've heard some amazing music projects recorded entirely remotely using technologies like virtual desktops and web audio. As a side project, I'm building an open-source web-based digital audio workstation that uses PureScript in the browser as its scripting language. My hope is to facilitate remote audio collaboration and open up new creative possibilites for musicians all over the world.

One of the biggest challenges in audio is parameter validation. There are so many numeric parameters and tracks in a modern audio project that it's really easy to saturate quickly, resulting in soul-crushing compression and limiting. Add more people to the equation, none of whom have the same mix in their head/ears, and it's a recipe for disaster. So one thing I wanted to get right from the outset was a representation of parameters where people could reason about acceptable ranges.

A lot of user-facing parameters in audio are represented as rational numbers, most often between 0 and 1. Bearing this in mind, I set out to build a library that allows programmers to reason about numeric parameters their audio programs (or any programs) right from their IDE.

Enter purescript-typelevel-rationals. They allow you to keep track of flows of rational numbers to know exactly what the possible range of output values will be. I hope that it helps musicians make more subtle mixes. With time, I'll add features like trigonometric functions, natural logarithms, complex numbers, and other things that can be expressed at the type level.

That's all well and good, but *what* is a rational number expressed as a type? I'm glad you asked!

## Type-level programming

Anyone that uses a strongly typed functional language works with the compiler to some extent as they write their code. Fingers tire, the brain starts to shut down, and at a certain point you think "ah, whatever, let the compiler do the hard work" as you press compile. There are IDE extensions for most languages that'll put these warnings in your editor so that you can tidy up right as you code.

Type-level programming, for me, is a way to enhance this experience. It teaches the compiler to catch mistakes based on the types you define. For example, if you find a way to define `PositiveFloat`

, then the compiler can warn you or downright fail the build anytime the value dips into the negatives. For audio applications, this is really important, as a lot of plugins have wanky behavior with negative input.

### My PureScript implementation

To start, I define a number not as a value, but as a *constrained value*. To the right of the colon is a constraint meaning `0 <= n < 1`

.

```
type Between0And1 = ConstrainedRatioI ((Nt (Lt Zero)) &&/ (Lt (P1 +/ P1)))
oneHalf = asConstraintedRational 1 2 :: Maybe Between0And1
```

This will yield a value of `1/2`

with the following type:

```
Just
( ConstrainedRatioI
( AndConstraint
( NotConstraint
(LessThanConstraint Zero)
)
(LessThanConstraint (PRational P1 P1))
)
)
```

Because our constraint is between 0 and 1, the compiler does not know that it is `1/2`

, but it does know that it is in an initial acceptable range. Had we tried to do the following, we would have gotten `Nothing`

because `3/2`

is greater than `1`

:

```
oneHalf = asConstraintedRational 3 2 :: Maybe Between0And1 -- Nothing
```

Traditionally, this type of validation function is implemented so that it yields `Maybe Float`

. While that's all well and good, we lose tons of information by working with a blunt instrument like `Float`

. Our type above may be a bit verbose (we can stash it in a type variable to save room), but it communicates our intention exactly.

Of course, a type with that wide of an ambitus only makes sense when you're not sure what the input will be, ie if you're reading from disk. If you know it's `1/2`

, just say it!

```
type ExactlyOneHalf = ConstrainedRatioI (EqConstraint (P1 +/ P2))
oneHalfPrecise = asConstraintedRational 1 2 :: Maybe ExactlyOneHalf
```

### invoke

The next important bit is `invoke`

. It takes a function from `a -> c`

, where `a`

is a constrained rational, and transforms it into a function of type `b -> c`

if `b`

's type is a sub-constraint of `a`

. For example, `0 <= n < 2`

is a sub-constraint of `-1 <= n < 2`

but not `-1 <= n < 1`

. The constraints and subconstraints can get as wild as you'd like, ie `(0 <= n < 2 & 4 <= n < -5) || n < -42`

. `purescript-typeleve-rationals`

figures it out.

To use invoke, let's take the `oneHalfPrecise`

we defined above. Now, let's create a function that accepts any value between `0 & 1`

and returns `0.25`

.

```
type OneQuarter = ConstrainedRatioI (EqConstraint (P1 +/ P4))
returnAQuarter :: Between0And1 -> OneQuarter
returnAQuarter _ = constConstrained $ resolve (CRProxy :: CRProxy OneQuarter)
```

Above, `resolve`

resolves a constraint into a rational if it is a single value (ie `1/4`

) and `constConstrained`

converts it back to a constrained rational. To see how powerful resolve is, try to mess with it. For example, in my `IDE`

, I changed the `NotConstraint (LessThanConstraint (PRational P1 P4))`

to `NotConstraint (LessThanConstraint (PRational P1 P5))`

. That means that the value is between `1/5`

and `1/4`

, so we can no longer resolve it to a single value. Sure enough, the IDE gets angry at me. Thanks, PureScript compiler!

Now that we have our function, let's invoke it!

```
myQuarterBack = invoke returnAQuarter <$> oneHalfPrecise
```

Neato, we get a quarter. But what if we tried to invoke it with something (gasp) outside of its proscribed range of `0 <= n < 1`

? Let's find out!

Wow, that compiler is livid. As it should be! We've tried to invoke a function with a value (`3/2`

) outside of its domain (`1/2`

). Going back to audio, think of all those ears that will be spared from gratuitous clipping. Unless you're into Merzbow, in which case you can make a constraint that the volume should always be *over* one.

### Adding numbers

Lastly, let's see how `invoke`

allows us to specialize functions, like an adder, for specific input.

First, we'll create an adder for numbers between `0`

and `1`

.

```
type Gte0lt1 = ((Nt (Lt Zero)) &&/ (Lt (P1 +/ P1)))
type Between0And1 = ConstrainedRatioI Gte0lt1
zeroToOneAdder =
addConstrainedRationals ::
forall (a :: ConstrainedRational).
InvokableRational
Gte0lt1
a =>
Between0And1 ->
( forall (b :: ConstrainedRational).
InvokableRational
Gte0lt1
b =>
Between0And1 ->
( forall (c :: ConstrainedRational).
AddConstraint a b c =>
ConstrainedRatioI c
)
)
```

Now that that's done, we can create a specialized function called `addTwoHalves`

that *only* accepts values that are `1/2`

and, consequentially, returns 1.

```
type IsOneHalf = EqConstraint (P1 +/ P2)
type IsOne = EqConstraint (P4 +/ P4)
addTwoHalves ::
ConstrainedRatioI IsOneHalf ->
ConstrainedRatioI IsOneHalf ->
ConstrainedRatioI IsOne
addTwoHalves i0 i1 = finalStep
where
curryFirstArg ::
Between0And1 ->
( forall (c :: ConstrainedRational).
AddConstraint
IsOneHalf
IsOneHalf
c =>
(ConstrainedRatioI c)
)
curryFirstArg = invoke zeroToOneAdder i0
finalStep ::
forall (c :: ConstrainedRational).
AddConstraint
IsOneHalf
IsOneHalf
c =>
ConstrainedRatioI c
finalStep = invoke curryFirstArg i1
```

The amazing thing here is that, when you give the function a signature, the type of the return value *has* to be `ConstrainedRatioI IsOne`

. Try it out yourself - if you put any other constrained rational as the return value, the program won't compile. That means that, just by solving type constraints supplied by `AddConstraint`

, the PureScript compiler is smart enough to follow two constrained values through the insides of our addition function and know what constrained is supposed to come out the other side.

## How?

Type-level programming in PureScript is just straight-up programming, no more, no less. All of the things I'm about to show you (kinds, functional constraints, type classes etc) take a while to fully understand, but for the purpose of this blog, the most important thing to grok are the equivalencies in a hand-wavy way.

In normal-land, this is how you'd define the Peano representation of the integers.

```
data Peano = Zero | Succ Peano
```

In type-level programming, you do the same thing using kinds.

```
foreign import kind Peano
foreign import data Zero :: Peano
foreign import data Succ :: Peano -> Peano
```

Next, onto functions. To define addition on the Peano integers, you can do (for example):

```
add :: Peano -> Peano -> Peano
add Zero x = x
add (Succ x) y = (Succ (Add x y))
```

Same thing in type classes:

```
class Add (a :: Peano) (b :: Peano) (c :: Peano) | a b -> c
instance addZero :: Add Zero b b
instance addSucc :: Add a b => Add (Succ a) b (Succ c)
```

Let's see how we'd implement a series of functions

```
mul :: Peano -> Peano -> Peano
mul Zero x = 0
mul (Succ x) y = Add (Mul x y) y
```

Same thing in type classes:

```
class Mul (a :: Peano) (b :: Peano) (c :: Peano) | a b -> c
instance mulZero :: Mul Zero b b
instance mulSuc :: (Mul a b c, Add c b d) => Add (Succ a) b d
```

For more information on what's going on under the hood, check out this post, which is where I learned to do all this stuff.

So that's it. When doing type-level programming in PureScript, you have access to recursive functions, pattern matching, and data constructors. What more does one need? If it weren't for that pesky varmant I/O, you could write your entire program just using types. But because my audio project will have I/O (ie a microphone and a speaker), you need some form of input validation and coersion to output. As we saw above, that's doable with `invoke`

.

Type-level programming in PureScript is lots of fun, and the community is implementing helpful things to make it easier all the time. After sprucing up this library a bit and adding more numeric features, I hope to get some killer WebAudio-API-music flowing through it!

## Top comments (0)