| description | applyTo |
|---|---|
Fsharp-instructions |
**/*.fs,**/*.fsx |
- Use 4 spaces for indentation (no tabs)
- Keep lines under 120 characters when possible
- Use 2 newlines to separate top-level constructs (types, modules, functions)
- Use 2 newlines to separate function definitions within a module
- Use single blank lines to separate logical sections within a function
- Use meaningful names for functions, types, and variables:
- Make variable names short when used in function bodies or functions not intended for public use
- Follow F# naming conventions:
- PascalCase for types, modules, and public members
- camelCase for local bindings and private members
- Use descriptive names over abbreviations
- Avoid using reserved keywords as identifiers, reserved keywords are:
The following tokens are reserved in F# because they are keywords in the OCaml language:
- asr
- land
- lor
- lsl
- lsr
- lxor
- mod
- sig
If you use the --mlcompatibility compiler option, the above keywords are available for use as identifiers.
The following tokens are reserved as keywords for future expansion of F#:
- break
- checked
- component
- const
- constraint
- continue
- event
- external
- include
- mixin
- parallel
- process
- protected
- pure
- sealed
- tailcall
- trait
- virtual
Additional style guidance:
- Namespace and opens:
- Place
namespace(or a single top-levelmodule) at the top of the file. - Keep
openstatements minimal and as close as possible to where they’re needed; prefer localopeninside modules over file-wide opens. - Avoid
openon very broad namespaces (e.g.,open System); prefer targeted opens (e.g.,open System.Text).
- Place
- One top-level module or namespace per file; the file name should match the top-level module/namespace for discoverability.
- Prefer modules and functions over classes; use object-oriented constructs only for interop or framework integration.
- Prefer qualified access:
- Use
[<RequireQualifiedAccess>]on DUs and modules to reduce name collisions and make call sites explicit.
- Use
- Use
///for XML documentation comments that appear in IntelliSense popups - Use
//for regular comments that document code internally but don't appear in popups - XML documentation should be used for:
- Type definitions and their purpose
- Public functions and their behavior
- Module-level documentation
- Regular comments should be used for:
- Record fields and discriminated union cases
- Private implementation details
- Code clarifications and explanations
- Format XML documentation consistently:
- Use
<summary>tags for multi-line descriptions - Use single-line
///for simple descriptions - Include parameter and return value documentation when helpful
- Include examples (
<example>) and remarks (<remarks>) for non-trivial APIs
- Use
// Good - XML documentation for types and public APIs
/// <summary>
/// Represents a patient with their medical information
/// </summary>
/// <remarks>
/// Enforces that illegal states (e.g., empty name) are prevented by smart constructors.
/// </remarks>
type Patient =
{
// The unique identifier for the patient
Id: PatientId
// The patient's full name
Name: string
// Optional date of birth
DateOfBirth: DateTime option
}
/// <summary>Calculates the appropriate dosage for a patient.</summary>
/// <param name="bodyWeight">Body weight in kg.</param>
/// <param name="medication">Medication type used to determine factor.</param>
/// <returns>Dose in mg.</returns>
/// <example>
/// let dose = calculateDosage 70.0<kg> Paracetamol
/// </example>
let calculateDosage bodyWeight medication = ...
// Good - Regular comments for implementation details
let private processData input =
// Convert input to internal format first
let normalized = normalizeInput input
// Apply business rules
applyRules normalized- Define types at the module level before functions that use them
- Use discriminated unions for modeling domain concepts
- Prefer records over tuples for data with multiple fields
- Use option types instead of null values
- Create wrapper types for primitive values to ensure type safety (single-case DUs)
- Use active patterns for complex pattern matching scenarios
- Use
[<NoEquality>]and[<NoComparison>]on aggregates that should not be compared structurally - Use
[<RequireQualifiedAccess>]for DUs and modules to avoid unqualified usage - Keep domain types immutable; prefer private constructors with smart constructors in modules
- Use
[<CLIMutable>]only for DTOs, not for domain types
// Good
[<Struct>]
type PatientId = private PatientId of string
[<RequireQualifiedAccess>]
type MedicationStatus =
| Active
| Discontinued
| Suspended of reason: string
type Patient = {
Id: PatientId
Name: string
DateOfBirth: DateTime option
}Do not make types or functions private, unless explicitly mentioned in a comment or being told to.
- Create specific modules for each domain type that shadow the type name
- Define the type first at the top level, then create a module with the same name
- This enables clean API usage like
Patient.create ... - Place constructor and core operations in the shadowing module
- Prefer smart constructors returning
Result<_,_>if validation is required
// Good - Type-first with shadowing module
/// Represents a patient in the medical system
[<NoEquality; NoComparison>]
type Patient = private {
Id: PatientId
Name: NonEmptyString
BirthDate: DateTime option
}
/// Functions for working with Patient instances
[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
module Patient =
/// Creates a new patient with validation
let create id name birthDate : Result<Patient, PatientError> =
result {
let! id = PatientId.create id
let! name = NonEmptyString.create name
return { Id = id; Name = name; BirthDate = birthDate }
}
/// Calculates the patient's age
let calculateAge currentDate (patient: Patient) =
// implementation
/// Validates patient data (returns unit on success)
let validate (patient: Patient) : Result<unit, PatientError list> =
// implementation
// Usage:
let patientRes =
result {
let! p = Patient.create "ABC12345" "John Doe" (Some birthDate)
return Patient.calculateAge DateTime.UtcNow p
}- Discoverability: IntelliSense shows both type and related functions together
- Consistency: Follows .NET and F# core library patterns (
List,Map, etc.) - Type Safety: Explicit return types in module functions ensure correctness
- Clean APIs: Natural, readable code that expresses intent clearly
- Encapsulation: Private constructors + smart constructors enforce invariants
- Keep functions small and focused on a single responsibility
- Use partial application and currying effectively
- Prefer immutable data structures
- Use pattern matching instead of if-else chains when appropriate
- Design for function composition and piping (
|>) and avoid deep indentation - Avoid boolean flags; model choices as discriminated unions
- Prefer total functions; avoid partial pattern matches—validate inputs early
- Separate pure business logic from I/O operations; pass dependencies as parameters
// Good
let calculateDosage bodyWeight medication =
match medication with
| Paracetamol -> bodyWeight * 10.0<mg/kg>
| Ibuprofen -> bodyWeight * 5.0<mg/kg>
| Custom dose -> dose
// Model choices as DUs instead of boolean flags
type Query = ById of PatientId | ByName of NonEmptyString
let handleQuery fetchById fetchByName = function
| ById id -> fetchById id
| ByName name -> fetchByName name- Use
Result<'T,'Error>for operations that can fail- Use exceptions only for unexpected or unrecoverable errors (system failures, programming errors)
- Avoid throwing exceptions in business logic
- Use
Option<'T>for values that might not exist - Prefer specific error types (DUs) over strings
- Aggregate validation errors using a DU or non-empty collection
- Chain error handling using
Result.bind, computation expressions, or helper modules - For async workflows, standardize on
Task<Result<'T,'Error>>with helper functions (AsyncResult)
// Good - typed errors
type DosageError =
| ExceedsMaximum of max: float<mg>
| NegativeDose
let validateDosage dose maxDose =
if dose < 0.0<mg> then Error NegativeDose
elif dose <= maxDose then Ok dose
else Error (ExceedsMaximum maxDose)
// Result computation expression
type ResultBuilder() =
member _.Bind(x,f) = Result.bind f x
member _.Return x = Ok x
member _.ReturnFrom x = x
let result = ResultBuilder()
// AsyncResult helpers based on Task<Result<_,_>>
module AsyncResult =
let bind (f: 'a -> Task<Result<'b,'e>>) (t: Task<Result<'a,'e>>) = task {
let! r = t
match r with
| Ok v -> return! f v
| Error e -> return Error e
}
let map f (t: Task<Result<'a,'e>>) = task {
let! r = t
return Result.map f r
}- Group related functionality in modules
- Use explicit module declarations
- Keep modules focused and cohesive
- Expose only necessary functions (prefer
internalfor non-public API) - Place types at the top of modules before functions
- Use nested modules for related functionality
- Create separate modules for DTOs, validation, and business logic
- Create consistent API modules that expose main functionality
- Use
.fsisignature files in libraries to control visibility and hide constructors and fields for domain types - Use
[<RequireQualifiedAccess>]for DUs and modules to keep call sites explicit
- Prefer SDK-style projects; avoid manual
AssemblyInfo.fsin new projects - Centralize common settings in
Directory.Build.propsand enable SourceLink - Use semantic versioning via git tags with MinVer or Nerdbank.GitVersioning
- Include assembly metadata via SDK properties (Title, Description, Company)
- Keep shared types and utilities in separate libraries
- Organize code into domain-specific libraries using
Informedica.{Domain}.Libnaming - Enable deterministic builds and repository metadata for traceability
Example SourceLink setup (Directory.Build.props):
<Project>
<PropertyGroup>
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
<Deterministic>true</Deterministic>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.*" PrivateAssets="All" />
</ItemGroup>
</Project>If you need explicit attributes, you can still include an AssemblyInfo.fs:
[<assembly: AssemblyTitleAttribute("Informedica.GenSolver.Lib")>]
[<assembly: AssemblyProductAttribute("Informedica.GenSolver.Lib")>]
[<assembly: AssemblyCompanyAttribute("halcwb")>]
[<assembly: AssemblyVersionAttribute("0.2.2")>]
do ()Tooling and quality gates:
- Enforce formatting with Fantomas (configure via
.editorconfigorfantomas-config.json) - Use FSharpLint for code smells and consistency
- Treat warnings as errors in libraries; use pragmas sparingly
- Add BenchmarkDotNet projects for hot paths
- Define units of measure for all physical quantities
- Use consistent unit handling patterns across libraries
- Ensure calculations preserve unit safety
- Create explicit conversion functions between compatible units
- Prefer
decimalfor financial values; usefloat/float32with explicit tolerances for scientific values - Validate ranges via smart constructors
[<Measure>] type mg
[<Measure>] type kg
[<Measure>] type mgkg = mg/kg
module Dose =
let calc (bw: float<kg>) (factor: float<mgkg>) : float<mg> = bw * factor- Write unit tests for all public functions
- Use property-based testing for complex logic
- Test edge cases and error conditions
- Keep tests readable and maintainable
- Create separate test projects for each library
- Test both success and failure paths
- Create test utilities for common setup operations
- Avoid
DateTime.Nowin tests; inject time via anIClock/provider - Add golden tests for serialization/deserialization stability
Example preferred test setup
open Expecto
open Expecto.Flip
let run =
runTestsWithCLIArgs [] [||]
// preferred test setup using Expecto.Flip
// enabling pipelining of the actual value to
// the expecto test with the message and expected value
test "Example test" {
// GOOD
// explicit expected result
let exp = 1
// GOOD
1
// pipeline actual to test with
// message as interpolated string with exp
|> Expect.equal $"1 should be equal to {exp}" exp
}
|> run- Use Expecto as the primary testing framework
- Use
runTestsInAssemblyWithCLIArgs [] argvin Main.fs for test discovery - Organize tests in nested modules that mirror the library structure
- Use
[<Tests>]attribute to mark test collections - Use
testListto group related tests together - Provide an async testing pattern for
Task/Asyncreturn values
// Test project structure
[<EntryPoint>]
let main argv =
runTestsInAssemblyWithCLIArgs [] argv
module Tests =
module DomainTests =
let tests = testList "Domain" [
// tests here
]
[<Tests>]
let tests = testList "LibraryName Tests" [
DomainTests.tests
]
// Async test example
testTask "async workflow returns Ok" {
let! res = workflowUnderTest ()
res |> Expect.equal "should succeed" (Ok 42)
}- Use descriptive test names with backticks for complex scenarios
- Include expected behavior in test names
- Use both
testandtestCasesyntax consistently - Write tests that clearly express intent and expected outcomes
test "substance nacl to mmol" {
// test implementation
}
test "``calculateDosage should return correct dose for paracetamol``" {
// test implementation
}- Use FsCheck integration through Expecto for property-based tests
- Configure custom generators for domain-specific types
- Set appropriate test counts for thorough coverage
- Use
testPropertyWithConfigfor custom FsCheck configurations
type Generators =
static member NonEmptyString() =
Arb.from<string>
|> Arb.filter (fun s -> not (System.String.IsNullOrWhiteSpace s))
let config = {
FsCheckConfig.defaultConfig with
maxTest = 1000
endSize = 100
arbitrary = [ typeof<Generators> ]
}
testPropertyWithConfig config "round-trip serialization" <| fun input ->
input
|> serialize
|> deserialize
= input- Use
Expect.equalwith descriptive failure messages - Use
Expect.isTrueandExpect.isFalsefor boolean assertions - Use
Expect.throwsfor exception testing - Prefer pipeline syntax with
|>for readability - Use Unquote for complex assertions when needed
result
|> Expect.equal "should be equal" expected
someCondition
|> Expect.isTrue "condition should be true"
(fun () -> dangerousOperation())
|> Expect.throws "should throw an exception"- Use lists or arrays of test cases for parameterized testing
- Create helper functions for common test patterns
- Use
forloops intestListfor generating multiple similar tests
let testCases = [
input1, expected1
input2, expected2
]
testList "parameterized tests" [
for input, expected in testCases do
test $"test with {input}" {
processInput input
|> Expect.equal "should match expected" expected
}
]- Test "there and back again" scenarios for serialization/deserialization
- Test boundary conditions and edge cases explicitly
- Create specific tests for error conditions and validation
- Test both positive and negative cases for business rules
test "there and back again, simple dto" {
let original = createTestData()
original
|> serialize
|> deserialize
|> Expect.equal "should roundtrip correctly" original
}- Create reusable helper functions for common test setup
- Use consistent patterns for test data creation
- Create custom generators for complex domain types
- Share common test utilities across test projects
let equals expected message actual =
Expect.equal actual expected message
let createTestPatient name age =
{ Name = name; Age = age; (* other fields *) }- Separate unit tests from integration tests
- Use TestServer for API testing when applicable
- Mock external dependencies appropriately
- Test configuration and environment setup
- Make time and randomness explicit dependencies (inject IClock/IRng) for deterministic tests
- Use appropriate precision for floating-point comparisons
- Test mathematical operations with edge cases (zero, negative, infinity)
- Include performance benchmarks for critical algorithms (BenchmarkDotNet)
- Test with large datasets when relevant
test "floating point comparison with tolerance" {
let result = complexCalculation()
let expected = 1.23456789
Accuracy.areClose Accuracy.veryHigh result expected
|> Expect.isTrue "should be within tolerance"
}- Use XML documentation for public APIs
- Include examples in documentation when helpful
- Document complex algorithms or business rules
- Keep comments focused on "why" rather than "what"
- Consider literate programming or script-based samples for runnable docs when appropriate
- Use sequences (
seq) for large datasets that don't need to be fully materialized - Consider
async/taskfor I/O operations - Profile before optimizing
- Prefer functional approaches but be pragmatic about performance
- Use
seqfor lazy evaluation of large datasets - Implement memoization for expensive pure functions
- Consider async patterns for I/O-bound operations
- Prefer
ValueOption(voption) in hot paths to reduce allocations - Prefer arrays for tight numeric work; prefer structs (
[<Struct>]single-case DUs) for small wrappers in hot paths - Ensure tail recursion or use folds to avoid stack growth
- Implement structured logging throughout the application
- Use dependency injection for logger instances
- Log at appropriate levels (Debug, Info, Warning, Error)
- Include correlation IDs for tracking requests
- Use message templates (e.g., Serilog style) instead of string interpolation
- Avoid logging PII; redact sensitive data (especially in medical contexts)
- Use environment variables for configuration
- Provide sensible defaults for optional settings
- Separate development, test, and production configurations
- Make configuration immutable once loaded
- Represent configuration as typed records and validate at startup
- Treat time and randomness as dependencies (inject IClock/IRng)
- Model the domain using F# types before implementing logic
- Use units of measure for quantities (mg, kg, ml, etc.)
- Make illegal states unrepresentable through type design
- Leverage F#'s type system to encode business rules
- Avoid primitive obsession: prefer value objects (single-case DUs) and non-empty collections
- Model workflows explicitly (e.g., state machines with DUs for states and transitions)
[<RequireQualifiedAccess>]
type PrescriptionState =
| Draft of DraftData
| Signed of SignedData
| Dispensed of DispensedData
module Prescription =
let sign draft : Result<PrescriptionState, Error> =
// validate…
Ok (PrescriptionState.Signed signedData)- Use Railway Oriented Programming for complex workflows
- Validate inputs at API boundaries
- Return structured errors with helpful messages
- Use async for all I/O operations
- Design APIs that support method chaining and fluent interfaces
- Provide Result/AsyncResult helpers and computation expressions to simplify composition
- Separate data models from business logic
- Use mapping functions between different representations (DTO ↔ Domain)
- Implement caching strategies for expensive data operations
- Design for both local and remote data sources
- Keep persistence concerns out of domain types; map at boundaries
- Separate constraint definition from solving logic
- Use variable and equation abstractions for mathematical modeling
- Implement logging and debugging capabilities for complex algorithms
- Design for extensibility with different solving strategies
- Provide reproducibility via explicit seed/control of randomness
- Use code generation for repetitive data access code
- Generate types from external schemas when appropriate
- Maintain generated code in separate files
- Document the generation process clearly
- Keep generated code isolated from handwritten domain code
- Minimize external dependencies
- Prefer pure functions over stateful operations
- Use dependency injection for external services
- Mock external dependencies in tests
- Keep boundaries thin and map exceptions to domain errors at the edge
- F# for Fun and Profit: Domain Modeling and Railway Oriented Programming — https://fsharpforfunandprofit.com/
- Domain Modeling Made Functional (Scott Wlaschin) — https://pragprog.com/titles/swdddf/domain-modeling-made-functional/
- Official F# Style Guide — https://learn.microsoft.com/dotnet/fsharp/style-guide/
- Fantomas (F# formatter) — https://github.com/fsprojects/fantomas
- FSharpLint — https://github.com/fsprojects/FSharpLint
- BenchmarkDotNet — https://benchmarkdotnet.org/