- .NET SDK pinned in #file:'global.json'
- Common parameters specified in #file:'Directory.Build.props'
- Central NuGet package version management – versions go in #file:'Directory.Packages.props', not in
.fsprojfiles - Build:
dotnet build FSharp.Collections.Immutable.slnx - Test:
dotnet test FSharp.Collections.Immutable.slnx
IMPORTANT: Every collection binding implementation file must have a corresponding .fsi signature file. The one
exception is the XTypes.fs file of a folder, which holds only type abbreviations and is its own signature: a signature
file without an implementation file does not compile (FS0240).
The comparer struct and the extension files of a module carry <DependentUpon> in the project file, the .fsi on the
module's .fsi and the .fs on its .fs, so that Visual Studio shows them as a tree under the module.
/
├── build/ – FAKE build scripts and release automation
│ └── CheckOrder.fs – checks the category order of the signature files with the Fantomas parser
├── docsSrc/ – FSharp.Formatting documentation source
├── src/FSharp.Collections.Immutable/ – main library
│ ├── Helper/ – internal helpers shared by the modules, one root module per file
│ │ ├── ImmutableCollectionUtil.fs – argument checks and the wrapping of arrays
│ │ ├── Comparers.fs – the default comparer of F# generic comparison
│ │ ├── ErrorStrings.fs – the exception messages of FSharp.Core and the .NET libraries
│ │ ├── Raise.fs – the exceptions of the FSharp.Core DetailedExceptions module
│ │ ├── ArrayChecks.fs – the checks that the functions of several arrays share
│ │ └── Arrays.fs – the algorithms over arrays that the default and Tuple modules share
│ ├── FlatList/ – ImmutableArray API surface and builders
│ │ ├── FlatListTypes.fs – the FlatList abbreviation
│ │ ├── FlatList.fs / FlatList.fsi – includes the nested Parallel module
│ │ ├── FlatListOption.fs / FlatListOption.fsi – Option namespace overrides
│ │ ├── FlatListTuple.fs / FlatListTuple.fsi – Tuple namespace overrides
│ │ ├── FlatListBuilder.fs / FlatListBuilder.fsi
│ │ └── FlatListCE.fs / FlatListCE.fsi
│ ├── IndexedList/ – ImmutableList API surface and builders
│ │ ├── IndexedListTypes.fs
│ │ ├── IndexedList.fs / IndexedList.fsi
│ │ ├── IndexedListOption.fs / IndexedListOption.fsi
│ │ ├── IndexedListTuple.fs / IndexedListTuple.fsi
│ │ ├── IIndexedList.fs / IIndexedList.fsi
│ │ ├── IIndexedListOption.fs / IIndexedListOption.fsi
│ │ ├── IIndexedListTuple.fs / IIndexedListTuple.fsi
│ │ ├── IndexedListBuilder.fs / IndexedListBuilder.fsi
│ │ └── IndexedListCE.fs / IndexedListCE.fsi
│ ├── Queue/ – ImmutableQueue API surface and CE
│ │ ├── QueueTypes.fs – the Queue and IQueue abbreviations
│ │ ├── Queue.fs / Queue.fsi
│ │ ├── QueueOption.fs / QueueOption.fsi
│ │ ├── QueueTuple.fs / QueueTuple.fsi
│ │ ├── IQueue.fs / IQueue.fsi
│ │ ├── IQueueOption.fs / IQueueOption.fsi
│ │ ├── IQueueTuple.fs / IQueueTuple.fsi
│ │ └── QueueCE.fs / QueueCE.fsi
│ ├── Stack/ – ImmutableStack API surface and CE
│ │ ├── StackTypes.fs – the Stack and IStack abbreviations
│ │ ├── Stack.fs / Stack.fsi
│ │ ├── StackOption.fs / StackOption.fsi
│ │ ├── StackTuple.fs / StackTuple.fsi
│ │ ├── IStack.fs / IStack.fsi
│ │ ├── IStackOption.fs / IStackOption.fsi
│ │ ├── IStackTuple.fs / IStackTuple.fsi
│ │ └── StackCE.fs / StackCE.fsi
│ ├── HashMap/ – ImmutableDictionary API surface and CE
│ │ ├── HashMapTypes.fs – the HashMap and IMap abbreviations
│ │ ├── HashMap.fs / HashMap.fsi – nested Tuple / ValueTuple submodules
│ │ │ ├── HashMap.CustomComparers.fs / .fsi – creates hash maps with comparers
│ │ │ └── HashMap.Extensions.fs / .fsi – ofArray / ofList / ofSeq for every binding shape
│ │ ├── HashMapOption.fs / HashMapOption.fsi
│ │ ├── HashMapTuple.fs / HashMapTuple.fsi
│ │ ├── IMap.fs / IMap.fsi
│ │ ├── IMapOption.fs / IMapOption.fsi
│ │ ├── IMapTuple.fs / IMapTuple.fsi
│ │ └── HashMapCE.fs / HashMapCE.fsi
│ ├── SortedMap/ – ImmutableSortedDictionary API surface and CE
│ │ ├── SortedMapTypes.fs
│ │ ├── SortedMap.fs / SortedMap.fsi
│ │ │ ├── SortedMap.CustomComparers.fs / .fsi
│ │ │ └── SortedMap.Extensions.fs / .fsi
│ │ ├── SortedMapOption.fs / SortedMapOption.fsi
│ │ ├── SortedMapTuple.fs / SortedMapTuple.fsi
│ │ └── SortedMapCE.fs / SortedMapCE.fsi
│ ├── HashSet/ – ImmutableHashSet API surface and CE
│ │ ├── HashSetTypes.fs
│ │ ├── HashSet.fs / HashSet.fsi
│ │ │ └── HashSet.CustomComparer.fs / .fsi – creates hash sets with a comparer
│ │ ├── HashSetOption.fs / HashSetOption.fsi
│ │ ├── HashSetTuple.fs / HashSetTuple.fsi
│ │ ├── ISet.fs / ISet.fsi
│ │ ├── ISetOption.fs / ISetOption.fsi
│ │ ├── ISetTuple.fs / ISetTuple.fsi
│ │ └── HashSetCE.fs / HashSetCE.fsi
│ ├── SortedSet/ – ImmutableSortedSet API surface and CE
│ │ ├── SortedSetTypes.fs
│ │ ├── SortedSet.fs / SortedSet.fsi
│ │ │ └── SortedSet.CustomComparer.fs / .fsi
│ │ ├── SortedSetOption.fs / SortedSetOption.fsi
│ │ ├── SortedSetTuple.fs / SortedSetTuple.fsi
│ │ └── SortedSetCE.fs / SortedSetCE.fsi
│ ├── FrozenMap/ – FrozenDictionary API surface and CE
│ │ ├── FrozenMapTypes.fs
│ │ ├── FrozenMap.fs / FrozenMap.fsi
│ │ │ ├── FrozenMap.CustomComparers.fs / .fsi
│ │ │ └── FrozenMap.Extensions.fs / .fsi
│ │ ├── FrozenMapOption.fs / FrozenMapOption.fsi
│ │ ├── FrozenMapTuple.fs / FrozenMapTuple.fsi
│ │ └── FrozenMapCE.fs / FrozenMapCE.fsi
│ ├── FrozenSet/ – FrozenSet API surface and CE (the BCL type keeps its name)
│ │ ├── FrozenSet.fs / FrozenSet.fsi
│ │ │ └── FrozenSet.CustomComparer.fs / .fsi
│ │ ├── FrozenSetOption.fs / FrozenSetOption.fsi
│ │ ├── FrozenSetTuple.fs / FrozenSetTuple.fsi
│ │ └── FrozenSetCE.fs / FrozenSetCE.fsi
│ └── ComputationExpressions.fs / .fsi – the computation expression instances
└── tests/FSharp.Collections.Immutable.Tests/ – MSTest test project
├── TestCategories.fs – centralized custom test category attributes
├── TestCategoriesTests.fs – enforces the test category rules
├── Contracts/ – shared contract bases, inputs and assertion helpers
└── <Collection>/ – ops records, contract classes, collection-specific,
builder, computation expression, Option and Tuple tests
The modules follow ADR 0001 – Per-collection modules (docsSrc/Explanations/ADR_0001_Collection_Modules.md):
- Each module groups its functions under banner comments
// ---- Category ----in the orderCreation,Builder,Properties,Access,Search,Transformation,Folding,Partitioning and grouping,Sorting,Random,Conversion,Type-specific, and sorts the functions of a category alphabetically (ordinal), active patterns last. The.fsfile repeats the order of its.fsifile.build/CheckOrder.fs(FAKE targetCheckOrder) enforces it. - A file with one module declares it as the root module of the file (
module FSharp.Collections.Immutable.FlatList); the type abbreviations of a folder live in itsXTypes.fs. No module carries[<CompiledName>]. try*functions returnvoption, tuples are struct tuples. The namespacesFSharp.Collections.Immutable.OptionandFSharp.Collections.Immutable.Tuplerepeat these functions withoptionresults and reference tuples.- No module function creates a collection with comparers: the
CustomComparers*(maps) andCustomComparer*(sets) structs do, and the modules create the structs withforComparers,forKeyComparerandforComparer. - Maps take bindings in every shape:
ofArray,ofListandofSeqare static extension members ofXExtensions, overloaded forKeyValuePair, reference tuples and struct tuples;addRangeandsetItemstakeKeyValuePairs, and the nestedTupleandValueTuplesubmodules of the module take the tuple shapes. - A function throws what the FSharp.Core function of the mirrored module throws for the same input:
ArrayforFlatList,Listfor the other sequence-like modules,MapandSetfor maps and sets. - Contract test bases in
Contracts/take a record of the module's functions; a collection derives one test class per capability that it supports. Expected values and exceptions come from FSharp.Core. The categories are attributes on the test classes; test methods carry none.
System.Collections.Immutable– immutable collections foundationFSharp.Core– F# language core libraryMSTest– test frameworkUnquote– expressive assertions for complex checksFAKE– build and release scriptingArgu– command-line parsing for build tooling
Use GitHub MCP tools for code search in these repositories when needed.
Agents discover servers from #file:'.mcp.json'; these are only hints on when to prefer one.
- For F# work, prefer the
F#server (FsLangMCP) overrg/plain text search whenever the task depends on symbol meaning, compile context, cross-project usage, diagnostics, or safe refactoring preview. Its tool package is pinned in #file:'.config/dotnet-tools.json' – rundotnet tool restorebefore first use. - Use the
GitHubserver for code search in dependency repositories. In Claude Code it authenticates through.claude/scripts/github-mcp-headers.ps1, which reuses the GitHub token stored by Git Credential Manager, because the server does not support the OAuth dynamic client registration Claude Code needs. - Use the
Microsoft Docsserver for official Microsoft and Azure documentation. - When adding or changing a server, keep
servers(VS Code / GitHub Copilot) andmcpServers(Claude Code) in sync and sorted alphabetically by key.
- Always use the latest F# 10 features over old syntax.
- If you are running outside of an IDE, or the IDE does not provide F# semantic tools, use FsLangMCP as the primary tool for F# code navigation, symbol discovery, diagnostics, usage search and refactoring preview. Prefer its semantic tools over plain text search.
- The compiler generates
IsCaseNameinstance properties (for exampleIsOk,IsNotFound) for each DU case – use them when a single-case check is needed. - Write at most one level of a type in the postfix ML style:
int[] seq,int voption[],seq<int list>– neverint array seqorint list list. Only the postfix levels count:seq<int array>andstruct (int array * int array)have one each.
- Prefer
taskCE overasyncCE. - When a method must return non-generic
Task, annotate the return type explicitly:member _.MyMethod (...) : Task = task { ... }. Never cast through pipelines. taskCE can awaitValueTaskAPIs directly.- Curried functions take the
CancellationTokenas their first parameter, so that it can be partially applied. - Never hand-roll wrappers such as
ValueTask (task { ... }),.AsTask ()round-trips or manualunit -> Taskthunks. Use the matchingIcedTasksCE instead (valueTask,valueTaskUnit,taskUnit,coldTask,cancellableTask,cancellableValueTask,backgroundTask, …) and always use the full CE names, not the short aliases (vTask,pvTask, …).
- Prefer
voption(ValueSome/ValueNone) overoption. Fields, members, parameters and values shared between threads included: none of them is a reason to pickoption. Exception: when an API hands you'T optionand has novoptioncounterpart, unwrap it withOption.defaultValue/Option.defaultWithdirectly – do not insertValueOption.ofOptionjust to switch modules. - The mirror case, an API that takes
'T option(an optional argument?name = …, a field typed'T option): stay inValueOptionthrough the whole chain and convert once, last –x |> ValueOption.bind _.Value |> ValueOption.toOption, neverx |> ValueOption.toOption |> Option.bind _.Value. - Prefer
struct ('T1 * 'T2)over reference tuples, and anonymous struct records (struct {| ... |}) over tuples for return types of public functions and methods. - Never group with
Seq.groupBy– useToLookupfromSystem.Linq. It groups once into anILookup<'Key, 'T>instead of re-grouping on every enumeration and does not allocate a tuple per group. Pass a lambda (xs.ToLookup (fun x -> keyOf x)), not a bare function value. - When casting sequence items use
Seq.cast<TargetType>instead ofSeq.map (fun item -> item :> TargetType). - When concatenating two sequences or lists, prefer
seq { yield! xs; yield! ys }(or[ yield! xs; yield! ys ]for lists) over the@operator orSeq.append. - When pipe operators are used on a materializable collection multiple times in a row, prefer
Seqmodule for the chain and materialize at the end. - Keep a pipeline in harmony: when consecutive steps can come from one module, take them from that module, the conversion at the end included –
xs |> Seq.filter isValid |> Seq.sort |> Seq.toArray, not… |> Seq.sort |> Array.ofSeq, anditems |> List.map _.Id |> List.toArray, not… |> Array.ofList. Change the module only where the steps that follow need the other collection. - Compare
System.Typevalues withType.(=) (a, b), theType.op_Equalityoperator. The F#=goes through generic equality and is several times slower, and comparing names is both slow and wrong:FullName.StartsWithis culture-sensitive without aStringComparison, and the full name of an array of a generic type, such asFSharpOption`1[[System.Int32, ...]][], starts with the name of the generic type. To recognize a generic type, checkt.IsGenericTypeand comparet.GetGenericTypeDefinition ()with its definition, such astypedefof<_ option>, throughType.(=).
- Prefer underscore lambda syntax like
Seq.map _.NameoverSeq.map (fun x -> x.Name), but only when the expression is a simple member access. Complex expressions likeSeq.where (fun x -> x.Name = name)orSeq.map (fun x -> x.Field1, x.Field2)cannot be simplified. A member chain ending in a method call is not complex:_.Changed.Subscribe(handler). The shorthand needs its input type known, so pipe the value in first –value |> ValueOption.map _.Id, notValueOption.map _.Id value(FS0072). Never write a space in_.MethodCall()– it breaks parsing. - Simplify
Seq.map (fun x -> someFunction x)toSeq.map someFunction. - Prefer interpolated strings over
printffunctions for string formatting. Format specifiers like$"%s{value}"are valid in interpolated strings and help type inference. - Pass an explicit
StringComparisonto everyEquals,StartsWith,EndsWith,IndexOf,ContainsandCompare, and an explicit comparer to everyHashSet<string>andDictionary<string, _>.Ordinalby default; culture-sensitive comparison is a decision, never a default. UseOrdinalIgnoreCaseonly where the thing compared really is case-insensitive. - A slice of a string that is only inspected – compared, trimmed, scanned, matched against a prefix – is a
ReadOnlySpan<char>(text.AsSpan (start, length)), not aSubstring: the substring allocates a copy per call, the span does not. Materialize withSubstring/ToString ()only for the value that leaves the function or is stored. - The
StringComparisonrule applies to spans unchanged:MemoryExtensionshasStringComparisonoverloads ofStartsWith,EndsWith,Equals,CompareTo,IndexOfandContainsforReadOnlySpan<char>–s.AsSpan().TrimStart(' ').StartsWith("//", StringComparison.Ordinal). The overloads without one are the generic element-wiseReadOnlySpan<'T>ones – ordinal forcharby accident, not by statement. - A slice that must be stored – kept in a record or class field, captured by a closure or used across a
let!/do!in ataskCE, put into a tuple, option or collection – isReadOnlyMemory<char>(text.AsMemory (...)), never aReadOnlySpan<char>: a byref-like value cannot be stored. Returning aReadOnlySpan<char>sliced from a string or from a span parameter is fine. - To compare two slices without building either, use
String.CompareOrdinal (a, aIndex, b, bIndex, length)orspanA.Equals (spanB, StringComparison.Ordinal).
-
Declare variables non-nullable; check for
nullat entry points only. -
Trust the SDK null annotations – do not add null checks when the type system says a value cannot be null.
-
Use
withNullfor null checks instead of boxing delegates/functions (avoidisNull (box value)). -
Prefer
matchonnulloverif isNull– it narrows the type and suppresses nullness warnings:// Preferred match someObject with | null -> () | someObject -> someObject.SomeProperty
-
Before suppressing a nullness warning (3261, 3262, …), exhaust these alternatives in order:
nonNull value– asserts non-null at runtime and fails fast.Unchecked.nonNull value– skips the runtime check; only when non-null was already verified upstream.- An inline
#nowarn/#warnonpair around the smallest possible scope – last resort for interop boundaries, centralised in a single helper rather than scattered across call sites.
-
Never suppress warnings file-wide;
#nowarnand#warnonare valid anywhere in a file.
-
A doc comment is either plain text with no tags at all, or fully explicit XML starting with
<summary>– never a mixture. The compiler adds<summary>by itself only when the comment has no tags, and silently escapes tags otherwise:- No tags anywhere → bare
///lines, no<summary>. - Any tag at all (
<see/>,<c/>,<para>,<param>,<returns>, …) → the comment must start with an explicit<summary>, and every<para>must be inside it.
// ✅ plain text — the compiler supplies <summary> /// Represents the result of a read operation. // ❌ a sibling tag without <summary> — the <param> is escaped into the summary and lost /// Reads an item. /// <param name="id">Item Id</param> // ✅ any tag present, so the comment is explicit XML throughout /// <summary>Reads an item.</summary> /// <param name="id">Item Id</param>
- No tags anywhere → bare
-
Keep
<summary>and<remarks>on one line each only when both are short – up to 60 characters of text each. When either is longer, write both over several lines, with each opening and closing tag on a line of its own:/// <summary>Returns the number of elements.</summary> /// <remarks>This is an O(1) operation.</remarks> /// <summary> /// The inputs of the set contracts as rows of <c>DynamicData</c>: none, one and several elements. /// </summary> /// <remarks> /// The property builds new arrays on each read, so that no test sees a row that another one changed. /// </remarks>
-
Every public API must have XML documentation.
-
On an explicit interface implementation (
interface X with member _.M (...) = ...), write/// <inheritdoc />alone instead of restating the interface member's documentation, unless this implementation has behavior worth calling out beyond what the interface already documents – write a normal<summary>/<remarks>there instead. -
Refer to types and members through
<see cref="Type.Member"/>, never through<c>or plain text.<c>is for literal values only (JSON, SQL, setting names). Refer to language keywords through<see langword="null"/>. -
Split multi-paragraph documentation into
<para>elements inside<summary>– bare line breaks are collapsed by documentation renderers. -
Write enumerations as
<list type="bullet">(ortype="number") with<item><description>…</description></item>, never as Markdown-style bullets.
Sort open statements alphabetically within groups: System first, Microsoft second, FSharp third; then other external namespaces; then this solution's namespaces. open type goes last in each group. Type and module aliases form a separate final group.
This is how to define a non-default F# class constructor:
type DerivedClass =
inherit BaseClass
new (``arguments here``) as ``created object``
=
// create any objects used in the base class constructor
let fieldValue = ""
{
inherit
BaseClass (``arguments here``)
}
then
``created object``.otherField <- fieldValue
[<DefaultValue>]
val mutable otherField : FieldTypeAlways prefer F# class initializers over property assignment! You absolutely must use F# class initializers instead of property assignment!
Class declaration:
type MyClass (someConstructorParam : string) =
member ReadOnlyProperty = someConstructorParam
member val MutableProperty1 = "" with get, set
member val MutableProperty2 = "" with get, setWrong:
let myClass = MyClass("some value")
myClass.MutableProperty1 <- "new value"
myClass.MutableProperty2 <- "new value"Right:
let myClass =
MyClass(
// constructor parameters go first without names
"some value",
// then mutable properties go next with names
MutableProperty1 = "new value",
MutableProperty2 =
// operations must be placed into parentheses
(5 |> string)
)// AutoOpen makes the module automatically available without an explicit open statement
// Extension makes the members visible to C#
[<AutoOpen; Extension>]
module MyTypeExtensions =
type MyType with
// Extension is visible to C#
// CompiledName makes the method name friendly to C#
[<Extension; CompiledName "ExtensionMethod">]
member this.ExtensionMethod (param1 : string) : ReturnType =
()- Use PascalCase for modules, types, and public members.
- Use camelCase for
letbindings, functions, private fields, and local variables. - Prefix interface names with
I. - Do not prefix type parameters with
T(e.g., use'Resultinstead of'TResult). - Name tests using spaces (e.g.,
member this.``Test name with spaces``() : Task = ...).
- Tests use MSTest 4.
- Collections in tests are arrays: build and inspect them with
toArrayand[| ... |], and compare them withCollectionAssert.AreEqual. Arrays are faster to build than F# lists, andCollectionAssertcompares them element by element, nested arrays included; it cannot work with F# lists. Convert to a list (toList,List.ofSeq) only to pattern-match on it or to call theListfunction that the tested function mirrors; a list that is an input of the tested function (ofList, a list given toofSeq) is a case of its own.- Never compare arrays with
Assert.AreEqual– it compares references. CollectionAssertcompares tuple elements withEquals, so an array inside a tuple is compared by reference; check such results with Unquote, where=compares the array structurally.
- Never compare arrays with
- Give the cases of a test as data rows and parameters of the test method instead of a loop over a list of cases inside the test:
[<DataRow>]for constants ([<TestMethod; DataRow 0; DataRow -1>]),[<DynamicData>]for rows that are computed or shared by several tests. Each row runs and reports as a separate test named after its arguments: a failing case does not stop the others, and the message does not need to repeat the input.- A
DynamicDatasource is a public static property that builds its rows on each read (static member Rows = ..., notstatic member val, whose rows every class that inherits the test would share), and a test never changes its row. typeof<_>cannot name an F# module, so the sources that several test classes share go into a static class ([<AbstractClass; Sealed>]):DynamicData (nameof TestData.Rows, typeof<TestData>). The sources of one class, a generic contract class included, are its own static members:DynamicData (nameof MyContract<'C>.Rows).- A row is a single value, a tuple, which is unpacked into the parameters of the method, or a
TestDataRow<'T>. Nested loops become one source:seq { for index in ... do for count in ... do struct (index, count) }. - MSTest names a row by the
ToStringof its values, so a row holds printable data – numbers, strings, arrays of them, F# union cases – and the test builds the collections and picks the functions from it. A collection or a function in a row gives a name likeImmutableHashSet`1[System.String]. - Wrap a row that is an array of a reference type (
string array, an array of reference tuples) inTestDataRow: MSTest spreads such an array into the parameters, as it does anobj array. - Do not set
TestDataRow.DisplayName: it replaces the whole name of the test, the name of the method included.
- A
StringAsserthas overloads withStringComparison.- Use
Assert.Containsinstead ofAssert.IsTrue (str.Contains ..., "message"), and do not put the actual value into the message. - Check collection size with
Assert.HasCount (expected, collection, "message")instead ofAssert.AreEqual (expected, collection.Length, "message")(or.Count/Seq.length); useAssert.IsEmpty,Assert.IsNotEmptyandAssert.ContainsSinglefor the zero, non-zero and single-item cases. On failure they report the actual count and items. - Try running tests with
--no-buildfirst, run them individually where possible, and use the trx format for results so failures can be consumed and fixed. - Use Unquote only for complex object/hierarchy assertions; for simple scalar checks prefer standard
Assert.*APIs. - Every
Assert.*call must include a failure message so output is self-explanatory. - Async tests must return
Task, notAsyncorTask<unit>– always declare) : Task = task {.
- Make only high-confidence suggestions when reviewing code changes.
- Write code with good maintainability practices, including comments on why certain design decisions were made.
- Handle edge cases and write clear exception handling.
- Never duplicate code unless explicitly allowed.
- All comments, documentation, README files, and markdown files must be written in English only.