Configuring Programs Using FSX scripts

Publication date: 2026-02-01

From time to time, I need to write some tool with quite complex configuration rules. Some examples are:

You can see that these programs explore different approaches to configuration. Some use declarative languages (like TOML or YAML), others use more imperative scripts.

In this post, I'd like to describe the approach I currently prefer as more flexible and convenient for the user and for the tool developer.

An example of such an approach is fully employed by Generaptor and Fabricator (and Nightwatch offers a hybrid approach, supporting both FSX and the classic configuration). The core idea is that the tools provide NuGet packages with a set of functions and types that allow users to write configuration scripts in F# using FSX files. This approach allows the users to define complex configurations using the power of any external tools they prefer (e.g., if they want to generate some parts of the configuration from their environment or custom data of any kind).

I find this approach really convenient for tools that you run periodically, but not the ones that are executed continuously, such as a web service. My examples are mostly focused on tools that the user executes on demand (say, on each pull request, or just periodically whenever they want to update their infrastructure state).

The core idea is: instead of implementing and publishing a .NET tool, or a standalone binary for a variety of supported platforms, you may choose to publish a tool as a set of NuGet packages, with exposed entrypoint in form of EntryPoint.Main(config, args). This way, the tool user is supposed to:

  1. Write an .fsx script in a typed language that uses this entry point.
  2. Prepare a strictly typed configuration object using a config DSL or any other means they wish.
  3. Invoke the tool using dotnet fsi my-script.fsx whenever they need it (possibly adding a shell alias or whatever they wish if they want to save some keystrokes when calling the tool regularly).

This provides the following benefits:

  1. The tool author doesn't need to invent or choose a configuration file format. All the modern formats — be in JSON, YAML, TOML or whatever — they all have their pros and cons. There's no ideal format that fits all the use cases. For some use cases, a static configuration format (accompanied by some sort of schema, e.g. a JSON schema) fits really well, while for some others, this quickly gets messy: either the configuration gets very complex to describe and validate, or it gets complex to parse at the tooling end.

    If you use an actual programming language instead of a static configuration file, it's trivial to consume for the tool, and not hard to produce for a user enlightened enough in the programming stack the tool is implemented in. Which, I agree, doesn't fit all the use cases either, but the tools I implement for myself fit this very well.

  2. The tool author doesn't need to think about publishing the tool for all the supported operating systems. Tools supporting the old configuration format have to publish their binaries (or rely on source builds) on a lot of system/architecture combinations, which is a lot of work to maintain — with not every kind of agent being freely available for building or testing.

    Instead, your only requirement is .NET SDK (which a lot of users already have, especially if they work near my area of expertise), and everything else is delivered by it.

  3. There are no problems with transitive dependencies: you don't need to search for a way to properly pack them, to use some sort of linking, or, for example, for the weight and separation of native packages prepared for different platforms. Packaging native projects or even .NET tools in some cases might be very complex due to legal and technical reasons.

    Instead, you just include your transitive dependencies as normal package dependencies, and they are installed on the user side whenever needed. You don't have to re-package them or do anything.

  4. For a user, it might be simpler to augment the configuration in any way they wish or generate it programmatically from any sort of data they have on their side. Additionally, it is easier to pass custom data filters or any kind of code through the configuration. You can make the configuration programmatically extensible (when the user implements some interfaces or provides functions that the system will execute when needed).

Note that this approach doesn't limit you from supporting file-based configuration or even providing usual executable builds if you wish. You can combine these approaches freely. For example, a path to the configuration file might be an optional parameter in your entry point function.

I would agree that this doesn't fit all the use cases, but for the stuff I write for myself it works really great. Next time instead of setting up an auto-packaging for a new CLI tool I develop, I just publish it onto nuget.org and then write a simple script on the use site:

#r "nuget: My.Mega.Tool, 1.0.0"
open My.Mega.Tool
let config = { (* … *) }
exit <| My.Mega.EntryPoint.Main config fsi.CommandLineArgs

This way, the user can pass any arguments to the tool if it supports that and benefit from the static typing and tooling support of the configuration format exposed in a programming language.