Less head-scratching. More shipping.

F# for the
rest of us.

F# with fewer sharp edges. Less squinting at “clever” code and more shipping!

Open source · MIT licensed · Opinionated on purpose

Wait. Which way does this go?01
let positiveIncrements values =
    List.filter isPositive <|
        List.map increment values
One direction. Less decoding.02
let positiveIncrements values =
    values
    |> List.map increment
    |> List.filter isPositive

Same result. Fewer forehead wrinkles.

Immutable dataExplicit failuresReadable by humansFewer clever surprises
01 / The philosophy

Clever is a moment.
Clear is a codebase.

“F# for dullards.” Affectionately. Because the person reading your code might be a teammate, a new hire, or you on a Tuesday with very little sleep. Let’s make their day easier.

Less code to decode.

Forward pipes. Named functions. Explicit cases. Spend your attention on what the program does, without a detour through operator trivia.

Fewer places for bugs to hide.

Values stay put. Failure gets a type. Side effects have a boundary. A smaller set of habits makes risky assumptions easier to spot.

One team. One vocabulary.

Standardize the readable path across the codebase. Reviews can focus on the decision being made, with fewer debates about how cleverly to spell it.

Write code that makes the next person say “oh, I get it.”
02 / The dull little rulebook

A reason for
every restriction.

69 rule IDs. 13 families. One idea: make the obvious thing the standard thing. Here’s what each rule asks for, and what it saves you from having to keep in your head.

69 rules in 13 familiesCatalog v1.0 · Experimental preview
STYLEKeep the reading easy.3 rules

A shared vocabulary beats a personal collection of clever tricks. Ordinary function calls and forward pipes make the order of work visible.

STYLE001

Pipes go forward.

Reject backward pipes: <|, <|| and <|||, including aliases and operator values. Use ordinary calls or |> instead.

Why it helps: Read the data flow in one direction, without mentally reversing a chain.

BeforeF#
let result =
    List.filter isPositive <|
        List.map increment values
AfterF#
let result =
    values
    |> List.map increment
    |> List.filter isPositive

Map first, filter second. The after version reads in the same order it runs.

STYLE002

Skip the operator alphabet soup.

Reject tuple pipes (||> and |||>) and application-defined symbolic operators outside the approved vocabulary.

Why it helps: Named functions tell the next reader what an operation means without a private operator glossary.

BeforeF#
let total =
    (price, quantity) ||> (*)
AfterF#
let lineTotal price quantity =
    price * quantity

let total = lineTotal price quantity

A name explains the operation; the reader does not have to decode a tuple pipe.

STYLE003

Give complicated accumulation a name.

Keep raw fold, scan, reduce and similar accumulation APIs inside exact, reviewed pure helpers.

Why it helps: A named helper explains intent; a generic accumulator makes every reader reconstruct the algorithm. A helper profile alone grants no permission.

BeforeF#
let total =
    items
    |> List.fold
        (fun acc item -> acc + item.Price) 0m
AfterF#
let total =
    items
    |> List.sumBy (fun item -> item.Price)

Use a specific operation when it fits. More complex accumulation belongs in an exact, reviewed helper.

MUTValues stay put.10 rules

Prefer transformations of immutable data. An immutable wrapper is only useful if its contents are immutable too.

MUT001

Let means let.

Reject mutable local and module bindings such as let mutable.

Why it helps: A name keeps its value, so understanding it does not require tracing every earlier statement.

BeforeF#
let mutable price = 100m
price <- price * 0.9m
price <- price + 5m
AfterF#
let price = 100m
let discountedPrice = price * 0.9m
let finalPrice = discountedPrice + 5m

Each name tells you which value you are looking at. Earlier values stay available.

MUT002

Make a new value.

Reject assignment, setters and field, property or index writes, including aliases.

Why it helps: Explicit replacement values make changes visible instead of quietly changing somebody else’s data.

BeforeF#
customer.Email <- newEmail
// Who else sees this change?
AfterF#
let updatedCustomer =
    { customer with Email = newEmail }

Return or pass on updatedCustomer explicitly; existing references still describe the original.

MUT003

Keep records and objects immutable.

Reject mutable fields, writable properties and stateful application object declarations.

Why it helps: A type should not look like a snapshot while carrying a hidden editing interface.

BeforeF#
type Account =
    { mutable Balance: decimal }
AfterF#
type Account =
    { Balance: decimal }

let deposit amount account =
    { account with
        Balance = account.Balance + amount }

The function returns an updated account instead of editing a shared object.

MUT004

No state in a little box.

Reject ref cells and their creation, reads and updates.

Why it helps: Moving mutation into a reference cell does not remove the need to reason about who can change it.

BeforeF#
let count = ref 0
count.Value <- count.Value + 1
let answer = count.Value
AfterF#
let increment count = count + 1
let answer = increment 0

A reference cell introduces a changeable location. A value and a function are easier to follow.

MUT005

Choose immutable collections.

Reject mutable collections and builders such as ResizeArray, Dictionary and StringBuilder in ordinary code.

Why it helps: Lists, maps and sets let callers share values without sharing a surprise write.

BeforeF#
let names = ResizeArray<string>()
names.Add("Ada")
names.Add("Grace")
AfterF#
let names = [ "Ada"; "Grace" ]

The list describes the finished value without an editing phase or an exposed builder.

MUT006

A method can mutate, too.

Reject references and calls to mutating methods, even when there is no assignment operator.

Why it helps: The rule follows the operation, so an Add call or alias cannot hide a state change.

BeforeF#
cache.Add(customerId, customer)
// Add mutates the existing cache.
AfterF#
let updatedCache =
    cache |> Map.add customerId customer

Map.add returns a new map. Calling a method named Add is not automatically the same operation.

MUT007

Keep shared state out of the core.

Reject shared mutable globals, singletons, caches and unapproved synchronization capabilities.

Why it helps: Behavior should not depend on which other request happened to run first.

BeforeF#
let mutable requestCount = 0

let recordRequest () =
    requestCount <- requestCount + 1
    requestCount
AfterF#
let recordRequest requestCount =
    requestCount + 1

State becomes an explicit input and output. Shared persistence and concurrency still need an approved boundary.

MUT008

Read-only is not a snapshot.

Reject arrays and aliasable mutable views as pure data.

Why it helps: Another owner can change the backing storage even when your reference only exposes reads.

BeforeF#
type Report =
    { Scores: int array }
// Another owner can edit the array.
AfterF#
type Report =
    { Scores: int list }

The contract asks for immutable values. Copying external arrays into this shape belongs at a reviewed boundary.

MUT009

Transform instead of marching.

Reject while and statement-oriented for loops outside approved effect adapters. Pure list comprehensions are distinct.

Why it helps: Map, filter and named transformations make the result clearer than a sequence of updates.

BeforeF#
let doubled = ResizeArray<int>()
for value in values do
    doubled.Add(value * 2)
AfterF#
let doubled =
    values
    |> List.map (fun value -> value * 2)

The transformation says what each output is, without a loop and an accumulating buffer.

MUT010

Check the contents, too.

Reject mutable or unapproved payloads nested in otherwise immutable types.

Why it helps: An immutable record containing a mutable collection still lets its meaning change underneath you.

BeforeF#
type Batch =
    { Pending: ResizeArray<int> option }
AfterF#
type Batch =
    { Pending: int list option }

Wrapping a mutable collection in a record and Option does not make its contents immutable.

CASTKeep the types doing their job.7 rules

Preserve useful static information. Statically safe upcasts to approved typed abstractions are allowed.

CAST001

No downcast guessing.

Reject :?> and downcast in ordinary code.

Why it helps: Model the alternatives explicitly so the compiler can check them before runtime.

BeforeF#
let name (value: obj) =
    (value :?> Customer).Name
AfterF#
let name (customer: Customer) =
    customer.Name

Ask for the type you need. The caller must supply a Customer before the function can run.

CAST002

Do not unpack a mystery box.

Reject unbox and unchecked unboxing, including qualified and aliased references.

Why it helps: Keeping values typed avoids a runtime assumption about what an object contains.

BeforeF#
let increment (value: obj) =
    (unbox<int> value) + 1
AfterF#
let increment (value: int) =
    value + 1

The typed parameter removes the runtime bet that the box contains an integer.

CAST003

No casts by another name.

Reject indirect cast APIs such as Seq.cast and LINQ Cast<T>.

Why it helps: A collection helper should not conceal the same runtime gamble as a direct cast.

BeforeF#
let names (values: obj list) =
    values
    |> Seq.cast<string>
    |> Seq.toList
AfterF#
let names (values: string list) =
    values

Keep elements typed from the start instead of casting the collection one item at a time.

CAST004

Keep information out of obj.

Reject boxing and erasure to obj, System.Object or objnull, including nested payloads, except exact boundary contracts.

Why it helps: Specific types preserve the information needed to read and check the program.

BeforeF#
type Event =
    { Payload: obj }

let event = { Payload = box 42 }
AfterF#
type Event =
    | CountChanged of int
    | NameChanged of string

let event = CountChanged 42

The union preserves both the data and its meaning; obj throws that information away.

CAST005

Model alternatives as alternatives.

Reject runtime type dispatch such as :?, tryUnbox and OfType in pure modeling.

Why it helps: A discriminated union makes the possible cases explicit and reviewable.

BeforeF#
let describe (value: obj) =
    match value with
    | :? string as text -> text
    | :? int -> "A number"
    | _ -> "Something else"
AfterF#
type Value = Text of string | Number of int

let describe value =
    match value with
    | Text text -> text
    | Number _ -> "A number"

The domain now lists its alternatives instead of making runtime type checks stand in for a model.

CAST006

Conversions should be visible.

Reject unapproved user-defined implicit and explicit conversion operators.

Why it helps: Readers should see where one meaning becomes another instead of discovering a hidden conversion.

BeforeF#
type Quantity =
    | Quantity of int
    static member op_Implicit(value: int) =
        Quantity value
AfterF#
type Quantity = private Quantity of int

let createQuantity value =
    if value > 0 then Ok (Quantity value)
    else Error "Must be positive"

An explicit, reviewed construction function can validate the meaning of the conversion.

CAST007

Validate numeric conversions.

Reject unapproved narrowing, truncating and numeric-to-enum or domain conversions.

Why it helps: Validation keeps overflow, lost precision and invalid values from becoming silent assumptions.

BeforeF#
let count (value: int64) =
    int value
AfterF#
let count (value: int64) =
    CheckedNumber.toInt32 value
    // Result<int, RangeError>

CheckedNumber is an illustrative reviewed helper that checks the range before conversion; it is not an FDull API.

NULLMake missing things explicit.5 rules

Use explicit absence and validation. Normalize external nullable values at an approved boundary.

NULL001

No surprise nothing.

Reject authored null values in pure code; exact boundary normalization can be reviewed separately.

Why it helps: Option makes absence something a caller can see and handle.

BeforeF#
type Customer =
    { Nickname: string | null }

let customer = { Nickname = null }
AfterF#
type Customer =
    { Nickname: string option }

let customer = { Nickname = None }

Option makes the missing value part of the model instead of a null hiding behind a reference.

NULL002

Construct a real value.

Reject Unchecked.defaultof and unapproved Unchecked APIs.

Why it helps: A default value can bypass the construction rules that make a type meaningful.

BeforeF#
let customer =
    Unchecked.defaultof<Customer>
AfterF#
let customer =
    Customer.create name
    // Result<Customer, ValidationError>

Use the domain’s reviewed constructor. Zeroed memory is not evidence of a valid Customer.

NULL003

Do not wish nullability away.

Reject unchecked nullability removal and unchecked null-related patterns.

Why it helps: A promise to the compiler is not a runtime validation of external data.

BeforeF#
let name (raw: string | null) =
    Unchecked.nonNull raw
AfterF#
// In an exact approved boundary normalizer:
let name (raw: string | null) =
    match raw with
    | null -> Error "Name is required"
    | text -> Ok text

The boundary checks the value instead of just removing its nullable type. Null handling still needs an exact grant.

NULL004

Zero is not always valid.

Reject unapproved zero/default initialization of invariant-bearing types and element shapes.

Why it helps: Memory full of defaults does not establish the conditions your domain requires.

BeforeF#
// A zeroed value may violate its invariant.
let quantity =
    Unchecked.defaultof<Quantity>
AfterF#
let quantity =
    Quantity.create requestedQuantity

Initialize an invariant-bearing value through validation, not through its default memory representation.

NULL005

Normalize at the edge.

Reject nullable or oblivious external shapes admitted directly as pure data.

Why it helps: The domain can work with explicit values and absence instead of repeating defensive null checks.

BeforeF#
// Framework data enters the domain.
type Customer =
    { Name: string | null }
AfterF#
// Domain input after boundary normalization.
type CustomerInput =
    { Name: string option }

An approved normalizer must actually handle the external nulls. Changing an annotation alone is not normalization.

ATTRAttributes are behavior.4 rules

Metadata can change construction, representation and trust. It belongs in review, just like a function body.

ATTR001

Approve attributes explicitly.

Reject attributes outside the profile’s approved catalog, including their targets and arguments.

Why it helps: A small annotation can have a large effect on generated behavior.

BeforeF#
[<System.Serializable>]
type Customer =
    { Name: string }
AfterF#
type Customer =
    { Name: string }

Leave an attribute out when its behavior is not needed. Necessary attributes require review of the exact use.

ATTR002

Protect the representation.

Restrict AllowNullLiteral, DefaultValue and CLIMutable to exact reviewed DTO or adapter needs.

Why it helps: Framework convenience should not quietly make domain data nullable or writable.

BeforeF#
[<CLIMutable>]
type Customer =
    { Name: string }
AfterF#
type Customer =
    { Name: string }

CLIMutable generates a default constructor and setters. The after record exposes neither shortcut.

ATTR003

No null-backed union tricks.

Reject application-defined UseNullAsTrueValue representations.

Why it helps: A union case should not introduce an unexpected null representation into application types.

BeforeF#
[<CompilationRepresentation(
    CompilationRepresentationFlags
        .UseNullAsTrueValue)>]
type MaybeName =
    | Missing
    | Present of string
AfterF#
type MaybeName =
    | Missing
    | Present of string

Keep the explicit cases without asking the runtime to represent one of your cases as null.

ATTR004

Do not self-award trust.

Reject unapproved attribute-based suppression, friend access, trust escalation and generated-code claims.

Why it helps: The code being checked should not be able to grant itself an exemption.

BeforeF#
[<assembly:
    System.Runtime.CompilerServices
        .InternalsVisibleTo("App.Web")>]
do ()
AfterF#
// App.Web uses the reviewed public API.
let customer =
    Customer.create name

Granting friend access widens what the caller can reach. Removing that grant preserves the intended public boundary.

REFLLeave runtime magic at the edge.4 rules

Keep reflective capabilities in exact, reviewed adapters where their effects and construction behavior can be understood.

REFL001

Use values, not runtime metadata.

Restrict authored Type, typeof, GetType and reflective metadata capabilities.

Why it helps: Static types keep the core understandable without a second program that inspects the first.

BeforeF#
let kind (value: obj) =
    value.GetType().Name
AfterF#
type Event =
    | OrderPlaced of int
    | OrderCancelled of int

let kind event =
    match event with
    | OrderPlaced _ -> "Placed"
    | OrderCancelled _ -> "Cancelled"

A domain decision can match named cases instead of inspecting a runtime type name.

REFL002

Construction should have a front door.

Reject unapproved reflective or dynamic invocation, private access and object construction.

Why it helps: Reflection can skip the functions and access restrictions that protect valid data.

BeforeF#
let customer =
    System.Activator
        .CreateInstance<Customer>()
AfterF#
let customer =
    Customer.create name

The reviewed constructor owns validation. A reflective factory can choose a different construction path.

REFL003

No constructor bypass.

Reject uninitialized-object creation APIs.

Why it helps: An allocated object is not necessarily an initialized, valid object.

BeforeF#
let customer =
    System.Runtime.CompilerServices
        .RuntimeHelpers.GetUninitializedObject(
            typeof<Customer>)
AfterF#
let customer =
    Customer.create name

Allocating a Customer without running construction does not establish its invariants.

REFL004

Keep executable code in view.

Reject unapproved runtime loading, code emission, evaluation, embedded scripting and type providers.

Why it helps: Reviewable source and build inputs are more predictable than code introduced behind the checker.

BeforeF#
let plugin =
    System.Reflection.Assembly.LoadFrom path
AfterF#
type Pricing = Standard | Member

let discount pricing =
    match pricing with
    | Standard -> 0m
    | Member -> 0.1m

When the alternatives are known, keep them in reviewed source instead of loading executable behavior from a path.

NATIVEKeep memory tricks out of business logic.3 rules

Native integration is outside the ordinary application vocabulary. This preview assigns no native interop profile.

NATIVE001

No unapproved native imports.

Reject P/Invoke, extern and native import declarations.

Why it helps: Native calls introduce behavior beyond the managed source and dependency contracts.

BeforeF#
open System.Runtime.InteropServices

[<DllImport("pricing-native")>]
extern int calculateDiscount(int tier)
AfterF#
type Tier = Standard | Member

let calculateDiscount tier =
    match tier with
    | Standard -> 0
    | Member -> 10

For this small decision, ordinary managed code is enough. Genuine native requirements need a separately approved design.

NATIVE002

Keep memory interpretation checked.

Reject unapproved NativePtr, Unsafe, Marshal and memory reinterpretation capabilities.

Why it helps: Ordinary domain code should not depend on manual layout or unchecked memory assumptions.

BeforeF#
let bytes =
    System.Runtime.InteropServices.Marshal
        .AllocHGlobal 128
AfterF#
// Model the business data directly.
type Reading =
    { SensorId: int
      Value: decimal }

Raw memory is not a business-data contract. Protocol decoding, when needed, belongs in a separately reviewed boundary.

NATIVE003

Use owned values.

Restrict pointers, byrefs, native integers, spans, memory views and address-taking to exact supported boundaries.

Why it helps: Avoiding borrowed or aliasable memory keeps lifetime and mutation puzzles out of the pure core.

BeforeF#
let increment (value: byref<int>) =
    value <- value + 1
AfterF#
let increment value =
    value + 1

Return the next value instead of borrowing a writable location owned by the caller.

DATAValidate data on the way in.5 rules

Decode transport data, normalize it, then validate it through domain construction. A serializer is not a validator.

DATA001

Deserialize DTOs, not domains.

Reject automatic materialization into designated domain types, even when nested in another target.

Why it helps: A serializer must not bypass private construction and manufacture invalid domain values.

BeforeF#
let customer =
    JsonSerializer
        .Deserialize<Customer>(json)
AfterF#
let customer =
    CustomerJson.decodeDto json
    |> Result.bind CustomerInput.validate

The illustrative approved decoder returns a normalized DTO. Validation constructs the private domain value and returns a modeled error.

DATA002

Give decoding a boundary.

Restrict raw serializers, ORM materializers and framework binders to approved adapters.

Why it helps: A single reviewed entry point makes construction and normalization behavior easier to audit.

BeforeF#
// Raw materializer in a request handler.
let dto =
    JsonSerializer
        .Deserialize<CustomerDto>(json)
AfterF#
// Concrete, reviewed adapter API.
let decoded =
    CustomerJson.decodeDto json
    // Result<CustomerDto, DecodeError>

The adapter owns decoding, normalization and supported failures; callers do not get raw materializer access.

DATA003

No build-anything escape hatch.

Restrict arbitrary generic or runtime-type materializer wrappers.

Why it helps: Callers should not be able to choose a protected domain target through an innocent-looking helper.

BeforeF# signature
val deserialize<'T> :
    string -> 'T
AfterF# signature
val decodeCustomerDto :
    string -> Result<CustomerDto, DecodeError>

The caller can request a DTO, but cannot swap in a private domain type as the generic target.

DATA004

Keep transport shapes plain.

Reject domain types, executable capabilities and other unapproved payloads in DTO targets.

Why it helps: Plain transport data separates untrusted input from validated meaning and behavior.

BeforeF#
type CreateOrderDto =
    { Quantity: Domain.Quantity }
AfterF#
type CreateOrderDto =
    { Quantity: int }

// A separate validation step calls:
// Quantity.create dto.Quantity

A transport contract carries the raw quantity. Only reviewed domain construction can turn it into a valid domain value.

DATA005

Normalize before deciding.

Restrict unnormalized boundary data and domain graphs used as transport contracts.

Why it helps: Explicit mapping keeps framework quirks outside domain decisions.

Known serializer and nested-domain checks exist; general interprocedural taint tracking does not.

BeforeF#
// dto still contains raw framework values.
Domain.register dto.Name dto.Email
AfterF#
let registration =
    RegistrationInput.normalize dto
    |> Result.bind RegistrationInput.validate
    |> Result.map Domain.register

These illustrative reviewed functions normalize external shapes, validate domain meaning and only then make the decision.

MODELMake invalid states harder to build.4 rules

Use approved data shapes, protected construction and explicit cases to make domain decisions easier to inspect.

MODEL001

Use data you can understand.

Reject unapproved pure data shapes, hidden executable payloads and unvetted opaque types.

Why it helps: A value’s visible shape should explain what it carries and what callers can do with it.

BeforeF#
type Invoice =
    { Amount: decimal
      OnPaid: unit -> unit }
AfterF#
type Invoice =
    { Amount: decimal
      Status: PaymentStatus }

Store the payment state as data. Decide on follow-up work explicitly instead of hiding an executable callback in the model.

MODEL002

Put invariants behind a constructor.

Require private representations for types explicitly designated as invariant-bearing in policy.

Why it helps: Callers must go through a construction boundary instead of inventing unchecked values.

Designation and private representation do not prove that a business invariant is correct.

BeforeF#
type Quantity = Quantity of int

let impossible = Quantity -5
AfterF#
type Quantity = private Quantity of int

let create value =
    if value > 0 then Ok (Quantity value)
    else Error "Must be positive"

Designate the type in policy and review its constructor. Private representation makes callers use that entry point.

MODEL003

Review every construction shortcut.

Reject unchecked helpers, widened construction signatures and unapproved friend-access bypasses.

Why it helps: An invariant is only as strong as every path that can create the type.

BeforeF#
// A new shortcut skips the validator.
let fromStorage value =
    Quantity value
AfterF#
let fromStorage value =
    Quantity.create value

A storage origin is not proof of validity. Keep the call on the existing reviewed construction path.

MODEL004

Name every domain case.

Reject catch-all patterns that erase explicit coverage of closed domain union decisions.

Why it helps: Adding a case should send you back to the decisions that need to handle it.

BeforeF#
let canShip status =
    match status with
    | Paid -> true
    | _ -> false
AfterF#
let canShip status =
    match status with
    | Paid -> true
    | Pending -> false
    | Cancelled -> false

When a new union case is added, explicit coverage makes the missing decision visible to the compiler.

EFFECTMake side effects show up.4 rules

Keep decisions pure and put I/O in narrow, approved boundaries. The checker inspects resolved API identities, not just their spelling.

EFFECT001

Call a reviewed vocabulary.

Reject external API references outside exact approved contracts.

Why it helps: A well-typed function can still mutate, perform I/O or bypass construction.

BeforeF#
let name =
    System.String.Intern suppliedName
AfterF#
let name = suppliedName

Interning touches runtime-managed shared state. If interning is unnecessary, keep the ordinary string value.

EFFECT002

Pass the world in.

Reject ambient time, randomness, environment, culture, configuration and I/O in pure code.

Why it helps: Explicit inputs make decisions easier to reproduce and test.

BeforeF#
let isExpired expiresAt =
    expiresAt < System.DateTimeOffset.UtcNow
AfterF#
let isExpired now expiresAt =
    expiresAt < now

A reviewed boundary supplies now. Tests can supply the same time every run.

EFFECT003

Keep behavior out of data.

Reject resources, service objects and unapproved executable capabilities crossing into pure data.

Why it helps: A record carrying a task or callback can hide work behind the appearance of a simple value.

BeforeF#
type QuoteInput =
    { Price: decimal
      GetDiscount: unit -> decimal }
AfterF#
type QuoteInput =
    { Price: decimal
      Discount: decimal }

Compute the external value before entering the pure decision, instead of handing it a callback that may do anything.

EFFECT004

Builders need contracts, too.

Reject unapproved computation-expression builders, providers and executable abstractions.

Why it helps: Friendly syntax should not smuggle effects into a supposedly pure workflow.

BeforeF#
// A custom builder hides how this runs.
let result =
    retrying {
        return charge order
    }
AfterF#
let command =
    PaymentDecision.forOrder order

// Boundary uses an exact reviewed API.
let result =
    PaymentAdapter.execute command

A reviewed command and boundary make execution explicit. Renaming or wrapping the builder alone would not grant permission.

ERRTreat failure as part of the job.10 rules

Represent expected failures explicitly and observe their results. Wrapping a throwing operation in Ok does not make it safe.

ERR001

Model expected failures.

Reject try … with in ordinary application and business code.

Why it helps: Result gives expected failure a visible place in the function’s contract.

BeforeF#
let quantity text =
    try
        Ok (System.Int32.Parse text)
    with _ ->
        Error "Bad quantity"
AfterF#
let quantity text =
    QuantityText.parse text
    // Result<Quantity, QuantityError>

The illustrative reviewed parser models expected validation failures. A catch-all can also swallow an unrelated defect.

ERR002

Do not throw the happy path off course.

Reject deliberate throwing, exception declarations and assert-as-validation for application flow.

Why it helps: Callers can handle modeled alternatives without discovering invisible exits.

BeforeF#
let divide numerator denominator =
    if denominator = 0m then
        failwith "Cannot divide by zero"
    else
        numerator / denominator
AfterF#
let divide numerator denominator =
    if denominator = 0m then
        Error "Cannot divide by zero"
    else
        Ok (numerator / denominator)

The signature now exposes expected failure. Result does not by itself eliminate arithmetic overflow or other unexpected exceptions.

ERR003

No catch-anything disguise.

Reject indirect catch/raise APIs and general-purpose catch-everything wrappers.

Why it helps: Turning every exception into a Result can mislabel programming defects as expected business errors.

BeforeF#
let catchAnything work =
    try Ok (work ())
    with error -> Error error
AfterF#
// Call an operation with a reviewed
// Result-returning contract.
let result = OrderValidation.validate input

A general catch wrapper makes all exceptions look expected; the reviewed validator models the failures it actually understands.

ERR004

Translate only known exceptions.

Restrict adapter exception translation to exact approved exception types.

Why it helps: Unexpected failures should stay visible instead of becoming a misleading business response.

BeforeF# · adapter
try
    File.ReadAllText path |> Ok
with _ ->
    Error NotFound
AfterF# · adapter
// Exact reviewed adapter exception contract.
try
    File.ReadAllText path |> Ok
with
| :? FileNotFoundException -> Error NotFound

Both snippets assume System.IO is in scope. The adapter handles only the approved missing-file exception; other failures remain unexpected.

ERR005

Errors should speak domain.

Reject exception objects and exception-containing payloads as pure business data.

Why it helps: A domain error describes what went wrong without importing runtime exception machinery.

BeforeF#
type CheckoutError =
    | Failed of exn
AfterF#
type CheckoutError =
    | OutOfStock of productId: int
    | PaymentDeclined

The caller sees business alternatives instead of unpacking an arbitrary runtime exception.

ERR006

Ask for the safe alternative.

Reject cataloged partial operations such as List.head and throwing parsers.

Why it helps: List.tryHead and approved validated access make absence or invalid input explicit.

BeforeF#
let firstCustomer customers =
    List.head customers
AfterF#
let firstCustomer customers =
    List.tryHead customers

An empty list becomes None instead of an exception. The caller now handles presence or absence explicitly.

ERR007

Read the result.

Detect obvious discarded or unobserved Result and cataloged must-handle operations.

Why it helps: A failure value cannot help if the caller throws it away.

Local-flow checks are partial; FDull does not prove linear or complete Result use.

BeforeF#
saveOrder order |> ignore
Ok "Saved"
AfterF#
match saveOrder order with
| Ok receipt -> Ok receipt
| Error error -> Error error

The after version observes and propagates both outcomes instead of claiming success after discarding the result.

ERR008

Recovery is a decision.

Reject implicit error-to-success or default conversions outside approved recovery helpers.

Why it helps: A fallback can hide a real failure; name and review the policy that makes it acceptable.

BeforeF#
let balance =
    loadBalance accountId
    |> Result.defaultValue 0m
AfterF#
let balance =
    match loadBalance accountId with
    | Ok value -> Ok value
    | Error error -> Error error

A failed balance lookup is not a zero balance. Preserve the error unless a reviewed recovery policy says otherwise.

ERR009

Give resources an owner.

Keep acquisition and cleanup constructs outside the pure core and inside approved adapters.

Why it helps: Resource lifetime stays close to the effect. Cleanup and exception recovery remain separate concerns.

BeforeF#
// Business calculation owns a file handle.
let total path =
    use reader = File.OpenText path
    parsePrices (reader.ReadToEnd())
AfterF#
// Reviewed boundary owns I/O and cleanup.
let totalText text =
    parsePrices text

Pass already-read data into the pure calculation. The adapter still has to own and dispose the file correctly.

ERR010

Finish, cancel or report.

Restrict blocking waits, fire-and-forget work, ad hoc background execution and cancellation swallowing.

Why it helps: Work needs an observable outcome and a clear lifetime, including when the caller goes away.

BeforeF# · boundary
let receipt =
    (PaymentAdapter.charge order).Result
AfterF# · boundary
// Approved task and adapter contracts.
task {
    let! receipt =
        PaymentAdapter.charge order cancellation
    return receipt
}

Await the result and pass cancellation through the reviewed boundary API. Merely using task syntax is not an automatic permission.

ARCHKeep the boundaries standing.4 rules

The approved project graph and dependency inventory help preserve the separation between the core, its adapters and its tooling.

ARCH001

Dependencies have a direction.

Reject forbidden project and dependency directions in the approved graph.

Why it helps: A pure core should not quietly become coupled to a serializer or an effectful host.

The preview supports an exact acyclic Release/net10.0 graph with literal project references.

BeforeProject references
<!-- App.Domain.fsproj -->
<ItemGroup>
  <ProjectReference
    Include="../App.Storage/App.Storage.fsproj" />
</ItemGroup>
AfterProject references
<!-- App.Boundary.fsproj -->
<ItemGroup>
  <ProjectReference
    Include="../App.Domain/App.Domain.fsproj" />
  <ProjectReference
    Include="../App.Storage/App.Storage.fsproj" />
</ItemGroup>

Move orchestration to the boundary and remove the domain’s storage reference. The domain should not depend on its persistence adapter.

ARCH002

Know what ships.

Reject unapproved dependencies and inputs covered by the protected inventory.

Why it helps: Review includes the code and assets that can enter through a dependency.

Transitive package, runtime, source and native asset inventory remains incomplete.

BeforeProject settings
<PackageReference
  Include="FDull"
  Version="*" />
AfterProject settings
<PackageReference
  Include="FDull"
  Version="0.2.0-preview.1" />

Pin the reviewed version, commit the lock file and use locked restore. Version pinning alone does not complete the transitive asset inventory.

ARCH003

Keep executable inputs accounted for.

Reject unapproved languages, scripts, projects and executable build extensions in checked workspaces.

Why it helps: A second implementation path should not sidestep the source and build policy.

BeforePython
# Business logic in an unclassified script.
def can_ship(status):
    return status == "paid"
AfterF#
// Authored in a classified, compiled .fs file.
type Status = Paid | Pending | Cancelled

let canShip status =
    match status with
    | Paid -> true
    | Pending -> false
    | Cancelled -> false

One checked implementation path. The new F# source and its build inputs still need explicit inventory and review.

ARCH004

Tests are not a production loophole.

Reject shipping dependencies on tests and tooling, and self-authorized profile exemptions.

Why it helps: Test conveniences should not become back doors into production behavior.

BeforeProject references
<!-- App.Domain.fsproj -->
<ProjectReference
  Include="../App.Tests/App.Tests.fsproj" />
AfterProject references
<!-- App.Tests.fsproj -->
<ProjectReference
  Include="../App.Domain/App.Domain.fsproj" />

Tests depend on shipping code. Shipping code does not acquire test permissions or helpers by depending on tests.

BUILDA check should mean what it says.6 rules

Keep the policy strict and bind the result to reviewed inputs. Some guarantees require organizational infrastructure outside the local package.

BUILD001

No hush-the-checker comments.

Reject source suppressions and diagnostic path spoofing.

Why it helps: A passing result should come from conformance, not from hiding the evidence.

BeforeF#
#nowarn "25"
let canShip status =
    match status with
    | Paid -> true
AfterF#
let canShip status =
    match status with
    | Paid -> true
    | Pending -> false
    | Cancelled -> false

Handle the missing cases instead of hiding the incomplete-match warning.

BUILD002

Pin the ground beneath you.

Reject compiler and toolchain policy drift, warning downgrades and skipped checks.

Why it helps: The same source should be evaluated against the same approved language and build settings.

BeforeProject settings
<PropertyGroup>
  <TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
AfterProject settings
<PropertyGroup>
  <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>

Keep the full pinned compiler preset as well. Restoring one setting does not replace toolchain and build-policy verification.

BUILD003

Incomplete is not a pass.

Fail analysis that is incomplete, malformed, crashed or out of resources.

Why it helps: A checker that did not finish cannot certify the input it was given.

BeforeF# · report consumer
let passes (report: FDull.SafetyReport) =
    report.Diagnostics.IsEmpty
AfterF# · report consumer
let passes (report: FDull.SafetyReport) =
    report.Complete
    && report.ExitCode = 0
    && report.Diagnostics.IsEmpty

An empty diagnostic list cannot turn a crash, timeout or incomplete analysis into success. Check the complete report and the process exit.

BUILD004

Check the code you actually build.

Bind analysis to source, reference, policy and compiler-input fingerprints.

Why it helps: A clean report for yesterday’s inputs should not approve today’s artifact.

Deployment-side admission, trusted artifact signing and additional race fixtures remain outside or unfinished.

BeforeBuild workflow
# A previous report covered different inputs.
dotnet fdull lint .
# Source or references change here.
dotnet build -c Release
AfterBuild workflow
# Verify the current reviewed inputs.
dotnet fdull verify .

verify rebuilds and compares inputs before accepting the result. Trusted artifact signing and deployment admission remain external responsibilities.

BUILD005

Exceptions must stay exact.

Validate capability entries and reject invalid, stale, widened, duplicate or unused grants.

Why it helps: A narrow exception should not quietly turn into permission for an entire file or profile.

Temporary exceptions are unsupported. Review identities and expiry require an external policy authority.

BeforePolicy fragment
{
  "File": "src/App/Decisions.fs",
  "Profile": "ADAPTER"
}
AfterPolicy fragment
{
  "File": "src/App/Decisions.fs",
  "Profile": "PURE"
}

Illustrative source-entry fragments, not complete policy files. If the function only makes pure decisions, remove unused grants and use its actual profile; ADAPTER never grants blanket permissions.

BUILD006

Protect who can say “passed.”

Require trusted CI check producers, repository rules and controlled merge and deployment admission.

Why it helps: A local check is not an organizational guarantee if anyone can replace or bypass it.

External control: not implemented by the NuGet package.

BeforeCI configuration sketch
required_check:
  name: fdull
  producer: any
  bypass: allowed
AfterCI configuration sketch
required_check:
  name: fdull
  producer: organization-owned
  bypass: restricted

Conceptual repository settings, not FDull config or a runnable CI file. The organization must enforce trusted check producers and controlled bypasses outside the NuGet package.

The examples are focused excerpts, with surrounding types or imports omitted where helpful. Application-specific helpers illustrate reviewed contracts; they are not FDull APIs. Each comparison explains one rule, rather than certifying a complete program.

These are the standard’s rules, not a claim that every part is fully enforced. Scope and exact reviewed capabilities matter; an adapter or test profile never grants blanket permission. See the executable-evidence inventory for implementation paths, test evidence and remaining work for each ID, or run dotnet fdull coverage. BUILD006 requires external organizational controls.

03 / Get comfortably dull

Small tool.
Strong opinions.

0.2.0-preview.1

This strict, experimental preview targets .NET SDK 10.0.200, F# 10.0 and net10.0. The worker needs Microsoft.NETCore.App 10.0.4, with runtime roll-forward disabled. Install the matching SDK and runtime first.

01

Install from NuGet.

Run in your repository. Create a tool manifest only if you don’t already have one.

TERMINAL
dotnet new tool-manifest
dotnet tool install FDull.Tool --version 0.2.0-preview.1
02

Pin the toolchain. Keep the strict defaults.

Create or update global.json with the pinned SDK:

GLOBAL.JSON
{
  "sdk": {
    "version": "10.0.200",
    "rollForward": "disable"
  }
}

For a new repository with no Directory.Build.props:

NEW REPOSITORY ONLY
dotnet fdull defaults > Directory.Build.props

If the file exists, inspect dotnet fdull defaults and merge its settings. The preset enables checked arithmetic, nullability, strict warnings, deterministic compilation, dependency locks and NuGet auditing.

03

Build, initialize, check.

Build once so the evaluated compiler inputs exist, then initialize a policy and check it. Pass a solution or project to restore/build if your directory is ambiguous.

TERMINAL
dotnet restore --use-lock-file
dotnet build -c Release --no-restore -m:1 --disable-build-servers
dotnet fdull init .
dotnet fdull lint .
dotnet fdull verify .

init starts every project in PURE with zero exceptions and records source/build fingerprints. It refuses to replace an existing fdull.json. A first lint failure means there’s code or explicit policy to review.

From Wonderly engineering

How we learned to love dull.

The thinking behind a smaller F# vocabulary, fewer clever surprises, and code that feels like a team sport.

Engineering post · Coming soon
A little honesty

Dull. Not magic.

FDull checks a deliberately restricted subset of ordinary F#. It uses FSharp.Compiler.Service to inspect resolved symbols, types and source syntax. Aliases and nested payloads are part of those checks. It is not a new language, and passing it is not proof of a bug-free program.

The broader standard is not fully implemented. Result-flow analysis, interprocedural validation and transitive dependency-asset checks have limits. Protected policy ownership, trusted CI, artifact signing and deployment admission need external infrastructure. Read the coverage inventory alongside the rulebook.

The current preview supports ordinary F# SDK workspaces with literal project references, authored .fs/.fsi files and the standard Release/net10.0 metadata layout. Multi-targeting, custom source generators and non-F# authored automation are outside its supported workspace model.