United Kingdom: +44 (0)208 088 8978

Custom Keywords in Computation Expressions

Isaac shows us how to create a basic F# computation expression with custom keywords.

We're hiring Software Developers

Click here to find out more

Computation expressions are a powerful feature of F#. We can use them for working with "effects" such as asynchronous data (through the async and task expression) as well as others such as result and option. Some expressions are built-in to F# whereas others are third-party libraries. Inside these blocks, you can use keywords such as let! and do! to perform "binds" over the effect - for Task, this would be the equivalent of await in C#; for result, this might be a railway-oriented programming-style flow taken care for you automatically.

task {
    let! data = Database.loadCustomersAsync()
    ... // more logic here...
    return
        data
        |> Array.map (fun customer -> customer.Name.ToUpper()) // returns a Task<string array>
}

However, another use-case for Computation Expressions is for custom keywords. These allow you to create what appear to be new commands that live inside a computation expression. For example:

customerCE {
    deleteCustomer 456 // deletes customer 456 from the DB
    loadCustomer 123 // loads customer 123 into memory
    changeName "Isaac" // changes customer 123's name
    setNameToUpper // upper case it
    save // save to DB
}

CEs with custom keywords are not especially difficult to create - they are actually just plain classes that have an attribute on each operation that you wish to expose:

module CustomerHelpers =
    type MyCustomerCE () =
        member _.Yield _ = ()

        [<CustomOperation "delete_customer">]
        member _.DeleteCustomer (state, customerId:int) =
            printfn "Deleting the customer"
            state

        [<CustomOperation "load_customer">]
        member _.LoadCustomer (state, customerId:int) =
            {| Id = customerId; Name = "Fred" |}

    let customerCe = MyCustomerCE()

Here, we create an expression that has two custom keywords on it. The first performs some side effectful operation (deleting a customer). The second would load a customer from the database and return it. We can use it as follows:

open CustomerHelpers

let result = customerCe {
    delete_customer 456
    load_customer 123
}

result here is now the value of the anonymous record returned by load_customer.

Computation Expression State

Computation expressions are "stateful", although this is to an extent hidden from you - essentially, you can chain together calls, with the first argument to a custom operation being the state which was returned from the previous call. For example, if we add another method to the class to set the customer's name to upper case, we can chain it with the call to load_customer:

    [<CustomOperation "to_upper">]
    member _.ToUpper (state:{|Id:int; Name : string|}) =
        {| state with Name = state.Name.ToUpper() |}

let result = customerCe {
    delete_customer 456
    load_customer 123
    to_upper
} // returns {| Id = 123;  Name = "FRED" |}

Pros and cons of computation expressions

Computation expressions can be very useful for creating a highly readable, imperative-style chain of instructions. If you design the CE well enough, it's entirely possible for them to be readable (and even writable) by a non-developer. And as they are simply classes, you can use features such as constructors, extension methods, overloads and interfaces with them to create powerful - albeit complex - abstractions. However, there are also several drawbacks with them:

  • They are expensive to create. You need to create custom operations and be aware of how to thread state through from one method to another.
  • Error messages are difficult to understand. If you call a CE method incorrectly, the error messages that are returned can be misleading - with other lines in the CE being underlined by the compiler.
  • Intellisense is sub-optimal. You do not get standard type-ahead intellisense in most IDEs.

For these reasons, I would advise you to think carefully about when and where to use computation expressions with custom keywords.

Summary

Computation Expressions are well known for being able to use let! for e.g. awaiting async workloads. However, you can also create your own ones, not only for let!-style workflows but also for domain-specific languages (DSLs) with custom keywords. Unfortunately, there's no such thing as a free lunch and - whilst custom operations with computation expressions look fancy - the editor tooling is somewhat lacking within them, so use them with care.