Logo Orsak

Generating code with Orsak.Myriad

Each side effect in Orsak comes with the same boilerplate: a provider interface exposing the effect, and a module with a function per member, wrapping it with Effect.Create. Environments that implement several providers are boilerplate too. Orsak.Myriad is a Myriad plugin that generates both at build time, from attributes on your interfaces.

The generated code shown on this page is from the showcase sample, where it can be seen in full.

Setting up

Reference Orsak.Myriad and Myriad.Sdk, and add a file for the generated code after the file with your interfaces, naming the latter as its MyriadFile:

<ItemGroup>
    <Compile Include="Library.fs" />
    <Compile Include="Generated.fs">
        <MyriadFile>Library.fs</MyriadFile>
    </Compile>
</ItemGroup>

<ItemGroup>
    <PackageReference Include="Orsak.Myriad" Version="..." />
    <PackageReference Include="Myriad.Sdk" Version="1.1.0" />
    <PackageDownload Include="Myriad" Version="[1.1.0]" />
</ItemGroup>

The code is generated on each build. The generated file can be committed, so it can be read and reviewed like any other code.

The PackageDownload is needed because Myriad.Sdk runs the Myriad tool from the NuGet cache, and the download it declares itself is in a props file, which NuGet doesn't import during restore. Without it, the build fails with MSB3954: Failed to compute hash for file '...\myriad\1.1.0\tools\net8.0\any\Myriad.dll'. Its version must match the version of Myriad.Sdk exactly. It can also go in a Directory.Build.props. Installing Myriad as a global tool doesn't help, as global tools aren't installed into the NuGet cache.

Myriad 1.x requires FSharp.Core 9 or later in any project that loads Myriad.Core directly.

In a project with several TargetFrameworks, each target framework runs Myriad on the same files, in parallel. When the generated code changes, they replace the same files at once, and the build can fail with Myriad exiting with code 1, or with an error that a file is in use or doesn't exist. Generating once, for the first target framework, before the target frameworks are built, leaves the files up to date for the others, which then don't write them:

<Target Name="GenerateBeforeInnerBuilds" BeforeTargets="DispatchToInnerBuilds" Condition=" '$(TargetFrameworks)' != '' ">
    <MSBuild Projects="$(MSBuildProjectFullPath)" Targets="BuildOnlySettings;MyriadSdkGenerateCode"
             Properties="TargetFramework=$(TargetFrameworks.Split(';')[0].Trim())" />
</Target>

BuildOnlySettings makes the references be resolved as they are for a build, as the build of that target framework reuses them. Without it, the xml docs and pdbs of the references aren't copied to the output.

Effects

[<GenEffects>] on an interface describing a side effect generates its provider interface, and a module named after the interface without its leading I, with a function for each member. Given

[<GenEffects>]
type IFace =
    abstract CountBeans: int * string -> Result<int, string>
    abstract PushButton: unit -> Task<unit>

the generated code is

type IFaceProvider =
    abstract Effect: IFace

module Face =
    let countBeans a b =
        Effect.Create(fun (er: #IFaceProvider) -> er.Effect.CountBeans(a, b))

    let pushButton () =
        Effect.Create(fun (er: #IFaceProvider) -> er.Effect.PushButton())

Tupled parameters become curried ones, and the functions are named in camel case. As with hand-written effects, a member returning a Result gives an effect failing with its error, and a member returning a Task, ValueTask or Async gives an effect awaiting it. The file must open Orsak, as some of the Effect.Create overloads come from it.

Environments

An environment implements the provider interfaces of all the effects a program uses. Rather than writing a type for it, [<GenEnvironment>] on an interface that inherits the providers generates a module with a create function, taking an anonymous record with an effect for each provider. Given

type IBeanCounter =
    abstract Count: unit -> int

type IBeanCounterProvider =
    abstract Effect: IBeanCounter

[<GenEnvironment>]
type IAppEnvironment =
    inherit IBeanCounterProvider
    inherit IGuidGenProvider
    inherit IRandomProvider

the generated code is

module AppEnvironment =
    let create
        (effects:
            {|
                BeanCounter: IBeanCounter
                GuidGenerator: Orsak.IGuidGenerator
                RandomGenerator: Orsak.IRandomGenerator
            |})
        : IAppEnvironment =
        { new IAppEnvironment

          interface IBeanCounterProvider with
              member _.Effect = effects.BeanCounter
          interface IGuidGenProvider with
              member _.GuidGenerator = effects.GuidGenerator
          interface IRandomProvider with
              member _.Effect = effects.RandomGenerator
        }

    let run effects = Effect.run (create effects)
    let runOrFail effects = Effect.runOrFail (create effects)

run and runOrFail create the environment and run an effect in it, and like the functions generated by [<GenEffects>], they need the file to open Orsak:

let environment =
    AppEnvironment.create {|
        BeanCounter = { new IBeanCounter with member _.Count() = 3 }
        GuidGenerator = GuidGenerator.defaultGen ()
        RandomGenerator = DefaultRandom(System.Random 42)
    |}

let! result = workflow () |> Effect.run environment
// or, in one go
let! result =
    workflow ()
    |> AppEnvironment.run {| BeanCounter = counter; GuidGenerator = guids; RandomGenerator = random |}

This suits tests well: each test builds the environment it needs in place, with the fakes it needs.

The generator only sees the syntax of your code, not its types, so it works by convention: inheriting IFooProvider is taken to mean a property Effect: IFoo. Orsak's own providers (IGuidGenProvider, ITimeProvider, ICacheProvider, ICancellationProvider and IRandomProvider) are known. Other providers are described in myriad.toml, in a section named after the provider:

EffectType = "Beans.IBeanCounter"
ProviderPropertyName = "Counter"

The generator fails the build with a message when the interface declares members of its own, inherits a generic interface other than those of scopes, inherits a provider whose effect it can't work out, or inherits two providers whose effects have the same name. A provider generated by [<GenEffects>] must be generated from an earlier file than the environment inheriting it, unless both are in a file using inline generation.

Extending an environment

An environment can inherit another [<GenEnvironment>] interface of the same file, and add providers of its own, such as the environment of a part of a program that needs more effects than the rest:

[<GenEnvironment>]
type IInlineEnvironment =
    inherit IClockProvider
    inherit IGreeterProvider

[<GenEnvironment>]
type ILocalizedEnvironment =
    inherit IInlineEnvironment
    inherit ILocaleProvider

Its create takes every effect, those it inherits included. As it inherits exactly one other environment, its module also gets an extend function, which creates it from an instance of the environment it inherits, taking only the effects it adds, and an Extend member on the inherited environment, calling extend:

module LocalizedEnvironment =
    let extend (outer: IInlineEnvironment) (effect: ILocale) : ILocalizedEnvironment =
        { new ILocalizedEnvironment

          interface IClockProvider with
              member _.Effect = (outer :> IClockProvider).Effect
          interface IGreeterProvider with
              member _.Effect = (outer :> IGreeterProvider).Effect
          interface ILocaleProvider with
              member _.Effect = effect
        }

    let create (effects: {| Clock: IClock; Greeter: IGreeter; Locale: ILocale |}) : ILocalizedEnvironment = ...

[<AutoOpen>]
module InlineEnvironmentExtensions =
    type IInlineEnvironment with
        member this.Extend(effect: ILocale) = LocalizedEnvironment.extend this effect

The new environment forwards the inherited providers to the instance it extends, so they are shared with it:

let localized = environment.Extend { new ILocale with member _.Culture = "sv-SE" }
// or
let localized = LocalizedEnvironment.extend environment locale

An environment adding a single provider takes its effect, as above. One adding several takes an anonymous record of them, as create does, such as {| Locale = locale; Clock = clock |}. When several environments extend the same one, Extend is overloaded on what they take. Environments taking the same effects get no Extend member, as F# couldn't tell them apart, but still get extend. An environment inheriting more than one other environment, adding no providers, or inheriting the interfaces of scopes itself, only gets create.

Scopes

Environments can also implement the interfaces of scopes, such as a unit of work or a transaction, which effects run in, from Orsak.Scoped. Inheriting ScopeProvider<'Scope> or CompletableScopeProvider<'Scope> adds a field to the record taken by create, named after the scope type without its leading I and followed by Scope, whose BeginScope the environment calls.

Inheriting IGrowsInto<'Scope, 'Inner> lets an effect run in a scope with Scope<'Scope>.Within or CompletingScope<'Scope>.Within, in the environment 'Inner, grown from the environment the scoped effect runs in. 'Inner must be a [<GenEnvironment>] interface of the same file that extends the one growing into it, adding a single provider whose effect is 'Scope. The generator implements Grow with the extend function of 'Inner, so the environment in the scope is the outer one and the scope:

type IUnitOfWork =
    inherit CompletableScope
    abstract Record: string -> unit

type IUnitOfWorkProvider =
    abstract Effect: IUnitOfWork

[<GenEnvironment>]
type IAppEnvironmentWithWork =
    inherit IGreeterProvider
    inherit CompletableScopeProvider<IUnitOfWork>
    inherit IGrowsInto<IUnitOfWork, IWorkEnvironment>

and [<GenEnvironment>] IWorkEnvironment =
    inherit IAppEnvironmentWithWork
    inherit IUnitOfWorkProvider

As the two interfaces refer to each other, they are declared with and, or in a rec module or namespace. The environment is created with a provider of the scope, and an effect is run in a scope from it:

let environment =
    AppEnvironmentWithWork.create {|
        Greeter = greeter
        UnitOfWorkScope =
            { new CompletableScopeProvider<IUnitOfWork> with
                member _.BeginScope() = ValueTask<_>(beginUnitOfWork ()) }
    |}

let inUnitOfWork () =
    eff {
        let! greeting = greet "unit of work"
        do! Effect.Create(fun (provider: #IUnitOfWorkProvider) -> provider.Effect.Record greeting)
        return greeting
    }
    |> CompletingScope<IUnitOfWork>.Within

CompletingScope<IUnitOfWork>.Within begins a unit of work, runs the effect in an IWorkEnvironment grown from the outer environment, and completes the unit of work if the effect succeeds. The scoped API is experimental, so using it gives warning FS0057, which can be turned off with #nowarn "57".

Inline generation

With <MyriadInlineGeneration>true</MyriadInlineGeneration> on a file, the generated code is appended to the end of the file instead of going in a file of its own. That keeps an environment next to the code using it, such as a test environment in a test file:

module rec MyTests

open Orsak
open Orsak.Myriad

[<GenEnvironment>]
type ITestEnvironment =
    inherit IButtonPusherProvider
    inherit IGuidGenProvider

let ``pushes the button`` () = task {
    let env = TestEnvironment.create {| ButtonPusher = fakePusher; GuidGenerator = GuidGenerator.defaultGen () |}
    let! result = pushButton () |> Effect.run env
    ...
}

// TestEnvironment is appended here

Runners

[<GenRunner>] is an alternative to [<GenEnvironment>] for effects given as IProvide<'t>. On an interface inheriting IProvide<'t> for each effect, it generates a record type implementing their providers, and a mkRunner computation expression that assembles one from the effects:

[<GenRunner(Name = "BasicRunner")>]
type ITest =
    inherit IProvide<IFace>
    inherit IProvide<IRandomGenerator>

// using the generated code
let runner =
    Runner.mkRunner {
        fromEffect random
        fromEffect face
    }

Face.countBeans 1 "" |> Effect.run runner

The effects can be given in any order.

Multiple items
val int: value: 'T -> int (requires member op_Explicit)

--------------------
type int = int32

--------------------
type int<'Measure> = int
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
Multiple items
module Result from Microsoft.FSharp.Core

--------------------
type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError
type unit = Unit
namespace System
Multiple items
type Random = new: unit -> unit + 1 overload member GetHexString: stringLength: int * ?lowercase: bool -> string + 1 overload member GetItems<'T> : choices: ReadOnlySpan<'T> * length: int -> 'T array + 2 overloads member GetString: choices: ReadOnlySpan<char> * length: int -> string member Next: unit -> int + 2 overloads member NextBytes: buffer: byte array -> unit + 1 overload member NextDouble: unit -> float member NextInt64: unit -> int64 + 2 overloads member NextSingle: unit -> float32 member Shuffle<'T> : values: Span<'T> -> unit + 1 overload ...
<summary>Represents a pseudo-random number generator, which is an algorithm that produces a sequence of numbers that meet certain statistical requirements for randomness.</summary>

--------------------
System.Random() : System.Random
System.Random(Seed: int) : System.Random
Multiple items
type AutoOpenAttribute = inherit Attribute new: unit -> AutoOpenAttribute + 1 overload member Path: string

--------------------
new: unit -> AutoOpenAttribute
new: path: string -> AutoOpenAttribute
val task: TaskBuilder

Type something to start searching.