Header menu logo UnionConfig

UnionConfig

Type-safe configuration for F# using discriminated unions. Read from env vars, .env files, or AWS SSM Parameter Store with typed parsing, validation, and secret masking.

One package, zero external dependencies.

type AppConfig =
    | DatabaseUrl
    | ApiKey
    | MaxRetries
    | DebugMode

let configDef =
    function
    | DatabaseUrl ->
        { Name = "DATABASE_URL"
          Kind = Manual
          ValueType = StringType
          Requirement = Required
          IsSecret = false
          Doc =
            { Description = "PostgreSQL connection string"
              HowToFind = "Check your database provider dashboard"
              ManagementUrl = None } }
    | ApiKey ->
        { Name = "API_KEY"
          Kind = Manual
          ValueType = StringType
          Requirement = Required
          IsSecret = true
          Doc =
            { Description = "External API key"
              HowToFind = "Generate at https://dashboard.example.com/keys"
              ManagementUrl = Some(Uri "https://dashboard.example.com/keys") } }
    | MaxRetries ->
        { Name = "MAX_RETRIES"
          Kind = AutoGenerated(Some "3")
          ValueType = IntType
          Requirement = Optional
          IsSecret = false
          Doc =
            { Description = "Max retry attempts"
              HowToFind = "Set to desired retry count (default: 3)"
              ManagementUrl = None } }
    | DebugMode ->
        { Name = "DEBUG_MODE"
          Kind = AutoGenerated(Some "false")
          ValueType = BoolType
          Requirement = Optional
          IsSecret = false
          Doc =
            { Description = "Enable debug logging"
              HowToFind = "Set to true or 1 to enable"
              ManagementUrl = None } }

Installation

# Core library (types, env reader, .env files, verification)
dotnet add package UnionConfig

# AWS SSM Parameter Store backend
dotnet add package UnionConfig.Ssm

# Interactive text editor workflow (CLI tools)
dotnet add package UnionConfig.TextEditor

*API Reference*

Reading Config

open UnionConfig.Types
open UnionConfig.Reader

let dbUrl = readString (configDef DatabaseUrl)
let retries = readInt (configDef MaxRetries)
let debug = readBool (configDef DebugMode)
let dbPort = readIntOrDefault (configDef DatabasePort) 5432

// read returns Result<ConfigValue option, string>
match read (configDef LogLevel) with
| Ok(Some(StringValue level)) -> printfn "LOG_LEVEL: %s" level
| Ok None -> printfn "LOG_LEVEL: (not set)"
| Error msg -> printfn "LOG_LEVEL error: %s" msg

ConfigValue Extraction

Pipeline-friendly typed extraction:

let dbUrl = read (configDef DatabaseUrl) |> Result.map ConfigValue.stringOption
let retries = read (configDef MaxRetries) |> Result.map ConfigValue.intOption
let debug = read (configDef DebugMode) |> Result.map ConfigValue.boolOption
let timeout = read (configDef RequestTimeout) |> Result.map ConfigValue.floatOption

// Custom parsing
let parseLogLevel (s: string) =
    match s.ToLowerInvariant() with
    | "debug" | "info" | "warn" | "error" -> Some(s.ToUpperInvariant())
    | _ -> None

let level = read (configDef LogLevel) |> Result.map (ConfigValue.customOption parseLogLevel)

ConfigRegistry

Reflection-based DU case discovery -- no manual allCases arrays:

open UnionConfig.ConfigRegistry

// Flat discovery
let allDefs = allDefs<AppConfig> configDef
let lookup = byName allDefs
let varNames = names allDefs

// Grouped discovery (nested DUs -> groups by wrapper case name)
let grouped = allDefsGrouped<AppConfig> configDef
// Returns: (string * ConfigVarDef array) array

Validation

let errors = validateRequired allDefs
// errors = ["DATABASE_URL: required but not set"; ...]

.env File Operations

open UnionConfig.EnvFile

let config = readEnvFile ".env"

// Diff two configs
let changes = compareConfigs staging prod
displayChanges changes

// Secret masking (PASSWORD, SECRET, KEY, API_KEY, SIGNING, TOKEN)
let display = maskValue "API_KEY" "sk-1234"  // "sk-1***"

// Write sectioned .env file from grouped defs
let grouped = allDefsGrouped<AppConfig> configDef
let sections = defaultSections grouped currentValues

// Generate a "MISSING CONFIG" banner for required entries without values
let allDefs = allDefs<AppConfig> configDef
let headerLines = missingEntriesHeader allDefs currentValues

writeEnvFile ".env" headerLines sections

The generated .env file includes a header highlighting missing required entries:

# ════════════════════════════════════════════════════════════════
# MISSING CONFIG — fill these in first
# ════════════════════════════════════════════════════════════════
#
# [Manual] API_KEY — External API key

# === Database ===
# PostgreSQL connection string
DATABASE_URL=
...

Verification

open UnionConfig.Verification

let results =
    allDefs
    |> Array.map (fun def ->
        let result =
            match def.Kind with
            | Infrastructure -> VerifySkipped "managed by infrastructure"
            | AutoProvisioned -> VerifySkipped "auto-provisioned"
            | Manual | AutoGenerated _ ->
                match read def with
                | Ok(Some _) -> VerifySuccess $"set (%A{def.ValueType})"
                | _ -> VerifyFailed "not set"
        (def.Name, result))

displayVerificationResults results

AWS SSM Parameter Store

UnionConfig includes an SSM config store that works with any parameter store backend. You provide the operations; UnionConfig handles path mapping, secret detection, and change application.

open UnionConfig.SsmConfigStore

// Provide your own SSM operations (e.g., via AWSSDK.SSM)
let ops: SsmOperations =
    { GetParameter = fun path -> (* your implementation *) None
      SetParameter = fun path value isSecure -> (* your implementation *) Ok()
      DeleteParameter = fun path -> (* your implementation *) Ok()
      GetParametersByPath = fun prefix -> (* your implementation *) [] }

// Create a store with path mapping
let store: SsmConfigStore =
    { Operations = ops
      PathMapping =
        { ToPath = fun name -> $"/myapp/staging/%s{name}"
          FromPath = fun path -> path.Replace("/myapp/staging/", "")
          PathPrefix = "/myapp/staging/" }
      IsSecret = fun name -> name = "API_KEY" }

let value = getValue store "DATABASE_URL"      // string option
let ok = setValue store "MAX_RETRIES" "5"       // bool
let all = loadAll store varNames               // Map<string, string>
let results = applyChanges store changes       // (key * success * wasDelete) array

Interactive Editor

open UnionConfig.ConfigEditor

// Apply defaults without opening editor
let result = populateDefaults getValueFn setValueFn getDefaultsFn writeLocalFileFn

// Full workflow: load -> $EDITOR -> diff -> verify -> confirm -> apply
editConfig loadConfigFn setValueFn writeConfigFileFn verifyChangesFn

Reference

See the Example App for a complete working example covering the full public API.

Types

type ConfigVarKind = Manual | AutoGenerated of string option | Infrastructure | AutoProvisioned
type ConfigValueType = StringType | IntType | BoolType | FloatType
                     | CustomType of typeName: string * validate: (string -> string option)
type ConfigRequirement = Required | Optional
type ConfigVarDef = {
    Name: string; Kind: ConfigVarKind; ValueType: ConfigValueType
    Requirement: ConfigRequirement; IsSecret: bool; Doc: ConfigVarDoc }
type ConfigValue = StringValue of string | IntValue of int | BoolValue of bool | FloatValue of float

Key Functions

// Reader
Reader.read              : ConfigVarDef -> Result<ConfigValue option, string>
Reader.readString        : ConfigVarDef -> string
Reader.readInt           : ConfigVarDef -> int
Reader.readBool          : ConfigVarDef -> bool
Reader.readIntOrDefault  : ConfigVarDef -> int -> int
Reader.readBoolOrDefault : ConfigVarDef -> bool -> bool
Reader.validateRequired  : ConfigVarDef seq -> string list

// ConfigValue extraction
ConfigValue.string / stringOption  : ConfigValue option -> string / string option
ConfigValue.int / intOption        : ConfigValue option -> int / int option
ConfigValue.bool / boolOption      : ConfigValue option -> bool / bool option
ConfigValue.float / floatOption    : ConfigValue option -> float / float option
ConfigValue.custom / customOption  : (string -> 'T option) -> ConfigValue option -> 'T / 'T option

// ConfigRegistry
ConfigRegistry.allDefs<'T>        : ('T -> ConfigVarDef) -> ConfigVarDef array
ConfigRegistry.allDefsGrouped<'T> : ('T -> ConfigVarDef) -> (string * ConfigVarDef array) array
ConfigRegistry.byName             : ConfigVarDef array -> Map<string, ConfigVarDef>
ConfigRegistry.names              : ConfigVarDef array -> string array

// EnvFile
EnvFile.readEnvFile           : string -> Map<string, string>
EnvFile.writeEnvFile          : string -> string list -> EnvFileSection array -> unit
EnvFile.defaultSections       : (string * ConfigVarDef array) array -> Map<string, string> -> EnvFileSection array
EnvFile.missingEntriesHeader  : ConfigVarDef array -> Map<string, string> -> string list
EnvFile.compareConfigs        : Map<string, string> -> Map<string, string> -> (string * string * string) array
EnvFile.maskValue             : string -> string -> string
EnvFile.displayChanges        : (string * string * string) array -> unit

// SsmConfigStore
SsmConfigStore.getValue      : SsmConfigStore -> string -> string option
SsmConfigStore.setValue      : SsmConfigStore -> string -> string -> bool
SsmConfigStore.deleteValue   : SsmConfigStore -> string -> bool
SsmConfigStore.loadAll       : SsmConfigStore -> string array -> Map<string, string>
SsmConfigStore.applyChanges  : SsmConfigStore -> (string * string * string) array -> (string * bool * bool) array

// ConfigEditor
ConfigEditor.populateDefaults : (string -> string option) -> ... -> PopulateResult
ConfigEditor.editConfig       : (unit -> Map<string, string>) -> ... -> unit

// Verification
Verification.displayVerificationResults : (string * VerificationResult) array -> unit

// Parsing
parseBool  : string -> bool option
parseValue : ConfigValueType -> string -> Result<ConfigValue, string>

License

MIT

type AppConfig = | DatabaseUrl | ApiKey | MaxRetries | DebugMode
val configDef: _arg1: AppConfig -> 'a
union case AppConfig.DatabaseUrl: AppConfig
union case Option.None: Option<'T>
union case AppConfig.ApiKey: AppConfig
union case Option.Some: Value: 'T -> Option<'T>
union case AppConfig.MaxRetries: AppConfig
union case AppConfig.DebugMode: AppConfig
val dbUrl: obj
val retries: obj
val debug: obj
val dbPort: obj
union case Result.Ok: ResultValue: 'T -> Result<'T,'TError>
val level: string
val printfn: format: Printf.TextWriterFormat<'T> -> 'T
union case Result.Error: ErrorValue: 'TError -> Result<'T,'TError>
val msg: string
val dbUrl: Result<obj,obj>
Multiple items
module Result from Microsoft.FSharp.Core

--------------------
[<Struct>] type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError
val map: mapping: ('T -> 'U) -> result: Result<'T,'TError> -> Result<'U,'TError>
val retries: Result<obj,obj>
val debug: Result<obj,obj>
val timeout: Result<obj,obj>
val parseLogLevel: s: string -> string option
val s: string
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
System.String.ToLowerInvariant() : string
System.String.ToUpperInvariant() : string
val level: Result<obj,obj>
val allDefs: obj
val lookup: obj
val varNames: obj
val grouped: obj
val errors: obj
val config: obj
val changes: obj
val display: obj
val sections: obj
val headerLines: obj
module Array from Microsoft.FSharp.Collections
val map: mapping: ('T -> 'U) -> array: 'T array -> 'U array
type 'T option = Option<'T>
type bool = System.Boolean
Multiple items
val int: value: 'T -> int (requires member op_Explicit)

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

--------------------
type int<'Measure> = int
Multiple items
val float: value: 'T -> float (requires member op_Explicit)

--------------------
type float = System.Double

--------------------
type float<'Measure> = float
Multiple items
val seq: sequence: 'T seq -> 'T seq

--------------------
type 'T seq = System.Collections.Generic.IEnumerable<'T>
type 'T list = List<'T>
type 'T array = 'T array
Multiple items
module Map from Microsoft.FSharp.Collections

--------------------
type Map<'Key,'Value (requires comparison)> = interface IReadOnlyDictionary<'Key,'Value> interface IReadOnlyCollection<KeyValuePair<'Key,'Value>> interface IEnumerable interface IStructuralEquatable interface IComparable interface IEnumerable<KeyValuePair<'Key,'Value>> interface ICollection<KeyValuePair<'Key,'Value>> interface IDictionary<'Key,'Value> new: elements: ('Key * 'Value) seq -> Map<'Key,'Value> member Add: key: 'Key * value: 'Value -> Map<'Key,'Value> ...

--------------------
new: elements: ('Key * 'Value) seq -> Map<'Key,'Value>
type unit = Unit

Type something to start searching.