Skip to content

feat: FrozenMap module with the vocabulary of the FSharp.Core Map module - #38

Open
xperiandri wants to merge 3 commits into
feat/hash-mapfrom
feat/frozen-map
Open

xperiandri wants to merge 3 commits into
feat/hash-mapfrom
feat/frozen-map

Conversation

@xperiandri

@xperiandri xperiandri commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Proposed Changes

Adds FrozenMap, a module over FrozenDictionary<'Key, 'Value>, after ADR 0001 (#23). This pull request is stacked on #30 (feat/hash-map). It reuses the map contracts and the Dictionaries helpers of #30.

  • Library files: everything is in FrozenMap/, in this compile order:
    • FrozenMapTypes.fs: the abbreviation FrozenMap<'Key, 'Value>. A root module cannot declare a type of its namespace, so the abbreviation has its own file, without a signature file (D2).
    • FrozenMap.CustomComparers.fsi and .fs: the struct that forKeyComparer creates (D12).
    • FrozenMap.fsi and FrozenMap.fs: 35 functions in the canonical category order, two of them on net10.0 only. 31 are the FrozenMap column of ADR Appendix B, with the parameter names of the FSharp.Core Map module. The other four wrap BCL members (see below).
    • FrozenMapOption.fsi/.fs and FrozenMapTuple.fsi/.fs: the Option and Tuple namespaces.
    • FrozenMap.Extensions.fsi and .fs: the creation members for every binding shape (D13).
    • FrozenMapCE.fsi and .fs, plus the frozenMap instance in ComputationExpressions.fsi and .fs, right after flatList.
    • The struct and the extension files nest under FrozenMap.fsi and FrozenMap.fs in Visual Studio through <DependentUpon>.
    • Each module is the root module of its file, and every file with public API has a signature file (D2).
    • The package description mentions System.Collections.Frozen. The changelog and the collection tables list FrozenMap.
  • Read and transform, never mutate (D1): a frozen map is built once and read many times. The module has no add, remove, setItem, change, clear, addRange, removeRange or builder.
    • choose, filter, map and partition rebuild the map in O(n) with the key comparer of the input. Their remarks say so.
    • filter and partition return the input itself for a part that keeps every binding.
    • keys and values return flat lists over the arrays of the map in O(1), without a copy. foldBack reads those arrays backwards.
    • containsValue compares with EqualityComparer<'Value>.Default, because a frozen map has no value comparer.
    • The functions with a name of Map throw what Map throws (D6). find and pick throw the KeyNotFoundException of Map, findKey throws the message of Seq.pick, and a null map throws ArgumentNullException.
  • Sequence inputs (D6): a seq argument throws ArgumentNullException that names the parameter for null and for a default flat list given as a sequence, which would otherwise fail with InvalidOperationException inside the BCL call. One call of checkNotNullOrDefaultSeq guards each of these before anything else: ofSeqBy, the ofSeq and ofSeqBy members of the struct (the extension overloads call them), and yield! and for of frozenMap { }, which check when the member runs and not when the buffer does. Arrays keep their null check. yield! of an array and of a frozen map checks null the same way (pairs, table), where it used to fail with NullReferenceException inside Run. The other inputs of frozenMap { } are functions that the compiler creates from the expression, and the resource of use, which F# accepts as null.
  • Wrappers of BCL members: four functions keep the semantics and the exceptions of the member they wrap (D6, D14).
    • containsBinding key value table wraps the explicit ICollection<KeyValuePair>.Contains. It tells in O(1) on average whether a key is bound to a value. A frozen map has no value comparer, so the value is compared with EqualityComparer<'Value>.Default, as containsValue does. tryFind followed by = uses F# equality, which differs for nan and for arrays. A null table or key throws ArgumentNullException.
    • ofReadOnlySpan span and ofSpan span wrap FrozenDictionary.Create. They read a span, where ofArray and ofSeq need an array or a sequence. They are members of CustomComparersFrozenMap too, which create with its key comparer, and the module functions call the members. A repeated key never throws: the last value wins and the first key stays (D14).
    • ofSpan exists because F# converts a Span to the ReadOnlySpan parameter only where the type arguments are known, with the warning FS3391. A call that leaves them to inference fails with FS0001.
    • copyTo destination destinationIndex table wraps the public CopyTo (KeyValuePair[], int). It fills an array that the caller owns, where toArray allocates a new one. It throws what the BCL throws: ArgumentNullException for a null table or array, ArgumentOutOfRangeException for an index below zero or beyond the length, and ArgumentException when the array has no room. The BCL gives the ArgumentOutOfRangeException no parameter name. The Span overload stays a call on the map, because a module function cannot be overloaded.
    • The changelog lists them and says how the BCL idioms map to the module: tryFind is TryGetValue, tryFind followed by ValueOption.defaultValue is GetValueOrDefault, (FrozenMap.forKeyComparer comparer).ofSeq table changes the key comparer, ofSeq of mapped pairs replaces an element selector, and the extension members compile to FrozenMapExtensions.OfArray, OfList and OfSeq.
  • CustomComparersFrozenMap and forKeyComparer (D12): FrozenMap.forKeyComparer keyComparer returns a struct whose members create frozen maps with that comparer: (FrozenMap.forKeyComparer StringComparer.OrdinalIgnoreCase).ofSeq pairs.
    • The members are empty, ofArray, ofList and ofSeq in the three binding shapes, ofReadOnlySpan, ofSeqBy, ofSpan, singleton and KeyComparer.
    • A frozen map has no value comparer and no builder. So the struct has one constructor and no builder, and the module has no forComparers.
    • A null comparer throws ArgumentNullException with the parameter name keyComparer. The default value of the struct stands for the default comparer, the one that the BCL takes for null.
    • ofSeq also gives an existing map the key comparer of the struct. A frozen map whose comparer Equals the comparer of the struct comes back itself, as ToFrozenDictionary does (D14). Two equal comparers need not be the same instance.
  • FrozenMapExtensions (D13): an auto-opened module, in FrozenMap.Extensions.fsi and .fs, that extends FrozenDictionary<'Key, 'Value> with static ofArray, ofList and ofSeq. Each one is overloaded for KeyValuePairs, reference tuples and struct tuples.
    • The module has no functions with these names. So FrozenMap.ofSeq pairs reaches the overloads through the abbreviation, also as pairs |> FrozenMap.ofSeq.
    • A dictionary, an F# Map and another frozen map are sources too.
    • The members are the members of the struct with the default comparer, so each shape has one implementation. C# does not see them as members of the BCL type; it calls the public static methods FrozenMapExtensions.OfArray, OfList and OfSeq, the struct or the BCL.
  • Edge cases follow .NET (D14): where Map and FrozenDictionary differ, FrozenMap follows FrozenDictionary, and the documentation of each function says so.
    • Every shape keeps the last binding of a key, as Map.ofSeq does. Of keys that only the comparer finds equal, the map keeps the first key with the value of the last binding, as the indexer of a Dictionary does. Map keeps the last key. The struct, the extension members, the span creations and frozenMap { } behave alike.
    • ofSeqBy wraps ToFrozenDictionary with a key selector. It rejects every repeated key with ArgumentException, even for equal elements.
    • ofSeq of an existing frozen map returns it when the comparers are equal, as above.
  • Alternate lookup (D11): on net10.0 only, alternateLookup and tryAlternateLookup wrap GetAlternateLookup and TryGetAlternateLookup.
    • The type of the alternate keys is the first type argument, <'Alternate, 'Key, 'Value>. A call reads FrozenMap.alternateLookup<int, _, _> table.
    • When the key comparer does not implement IAlternateEqualityComparer<'Alternate, 'Key>, alternateLookup throws InvalidOperationException and tryAlternateLookup returns ValueNone.
    • An F# generic function cannot take a span as a type argument. So the documentation points span alternates, such as ReadOnlySpan<char> for string keys, to the methods of the map.
  • Option and Tuple namespaces and the computation expression (D3, D7):
    • Option.FrozenMap repeats choose, pick, tryFind, tryFindKey and tryPick with options, and tryAlternateLookup on net10.0.
    • Tuple.FrozenMap repeats partition, toArray, toList and toSeq with reference tuples.
    • frozenMap { } collects yield struct (key, value) and yield KeyValuePair in a Dictionary and freezes it once, when it runs. A later binding of a key replaces the value and leaves the key.
    • FrozenMapCE keyComparer { } takes a key comparer and rejects null. FrozenMapCE.fsi carries its documentation.

Tests: 4,296 on net8.0 and 4,310 on net10.0. 554 and 568 of them are new; the 14 more on net10.0 test the alternate lookups. The tests compare arrays and take their cases as data rows, so every case runs and reports as a test of its own.

  • FrozenMap derives the reading, transformation and creation contracts of feat: HashMap and IMap modules with the vocabulary of the FSharp.Core Map module #30. A frozen map has no mutation contract. The reading contract has a field for containsBinding; the tests of its value comparer stay with the maps that have one.
  • The creation contract runs twice:
    • for the extension members, with the default comparer;
    • for CustomComparersFrozenMap, with a key comparer of absolute values. Every created map must find each key by its negation.
  • Both runs cover the three shapes on lists, arrays and sequences, on bindings that repeat a key, and on a dictionary, an F# map and a frozen map.
  • CustomComparersFrozenMapTests cover:
    • a null comparer in the constructor and in forKeyComparer, with the parameter name;
    • the comparer of every member, and the default value of the struct;
    • the first key with the last value in every shape;
    • ofSeq of an existing map: it comes back itself exactly when its comparer equals the comparer of the struct. Two distinct comparers of one culture, StringComparer.Create (CultureInfo.InvariantCulture, true) twice, are the equal ones;
    • ofReadOnlySpan and ofSpan of the struct: they copy the span, keep the first key and the last value of keys that the comparer finds equal, and create the empty map of the comparer for an empty span.
  • FrozenMapExtensionsTests check that the last value wins and the first key stays in every member and shape, with copies of a string that the default comparer finds equal, and that pipelines resolve the shape of their source.
  • FrozenMapTests cover:
    • null inputs of every function, with the parameter names;
    • the key comparer of every transformation, on small and large maps;
    • the results that are the input itself;
    • the arrays of keys and values;
    • containsBinding: the key in the key comparer and the value in the default comparer, nan, which F# equality does not find, and a null key on every kind of map;
    • ofReadOnlySpan and ofSpan: the bindings of Map.ofSeq, a copy of the span, the first key with the last value, an array and a Span where the types are known, and a Span whose type arguments the call infers;
    • copyTo: the order of enumeration from the index, the elements around the copy, an empty map at the end of the array, and the exceptions of the BCL for an index outside the array, an array without room and a null array;
    • the alternate lookups of int64 keys by int alternates.
  • FrozenMapComputationExpressionTests check that every member of frozenMap { } keeps the earlier key and takes the later value, and that FrozenMapCE rejects a null comparer.
  • The sequence inputs have a row per function for null and for a default flat list, for the module, the members of the struct and frozenMap { }; the extension overloads run through the module and the struct. A default flat list of strings given as seq<obj> has a row per module (FlatListOracles.defaultStringsAsObjects), and a flat list that is not default is checked to pass. yield! of a null array and of a null frozen map has a row each.
  • The namespace tests prove the shadowing with type annotations. After open FSharp.Collections.Immutable.Tuple, FrozenMap.ofArray, ofList and ofSeq still reach the extensions.
  • The rows are printable: the names of functions in tables of functions, DataRow constants, and the cases of BindingPredicate.
  • Each new behaviour was broken once on purpose, and tests of that behaviour failed.

Types of changes

What types of changes does your code introduce to FSharp.Collections.Immutable?
Put an x in the boxes that apply

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

FrozenMap is a new module, and no existing function changes.

Checklist

Put an x in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code.

  • Build and tests pass locally
  • I have added tests that prove my fix is effective or that my feature works (if appropriate)
  • I have added necessary documentation (if appropriate)

Further comments

ADR 0001, D14 settles the edge cases in which Map and FrozenDictionary differ, so no question is open for this pull request. The module follows FrozenDictionary in all three:

  • the key of the first of two keys that the comparer finds equal stays, with the value of the last binding;
  • ofSeqBy rejects every repeated key, where HashMap.ofSeqBy accepts a repeated equal element, as ToImmutableDictionary does;
  • ofSeq of an existing map returns it when its comparer equals the requested one, where HashMap needs the same comparer instance, as WithComparers does.

Two notes for the review:

  • The BCL creates an empty frozen dictionary with a comparer only through ToFrozenDictionary of an empty source, so the empty member of the struct calls it. It returns FrozenDictionary.Empty for the default comparer.
  • copyTo and containsBinding leave the checks of the destination, the index and the key to the BCL, so their exceptions and parameter names are the BCL's. The one exception without a parameter name is the ArgumentOutOfRangeException for the index. The tests pin it against the BCL method itself.

🤖 Generated with Claude Code

Copilot AI balanced review requested due to automatic review settings October 18, 2026 15:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

Static review identified no blocking issues, with extensive contract and collection-specific coverage; builds and tests were not independently executed.

No open issues 🎉

What changed in this PR

Adds FrozenMap bindings over FrozenDictionary, following ADR 0001 and reusing the shared map helpers and contracts.

Changes:

  • Adds read and transformation APIs, custom-comparer creation, and .NET 10 alternate lookups.
  • Adds Option/Tuple variants, creation overloads, and frozenMap computation expressions.
  • Integrates tests, package metadata, and documentation.
File Description
tests/​FSharp.Collections.Immutable.Tests/​FSharp.Collections.Immutable.Tests.fsproj Registers FrozenMap tests.
tests/​FSharp.Collections.Immutable.Tests/​FrozenMap/​FrozenMapTupleTests.fs Tests reference-tuple variants.
tests/​FSharp.Collections.Immutable.Tests/​FrozenMap/​FrozenMapTests.fs Tests collection-specific behavior and alternate lookups.
tests/​FSharp.Collections.Immutable.Tests/​FrozenMap/​FrozenMapOptionTests.fs Tests option variants and shadowing.
tests/​FSharp.Collections.Immutable.Tests/​FrozenMap/​FrozenMapOps.fs Supplies shared contract adapters.
tests/​FSharp.Collections.Immutable.Tests/​FrozenMap/​FrozenMapExtensionsTests.fs Tests creation overloads and pipelines.
tests/​FSharp.Collections.Immutable.Tests/​FrozenMap/​FrozenMapContractTests.fs Applies shared map contracts.
tests/​FSharp.Collections.Immutable.Tests/​FrozenMap/​FrozenMapComputationExpressionTests.fs Tests computation expressions.
tests/​FSharp.Collections.Immutable.Tests/​FrozenMap/​CustomComparersFrozenMapTests.fs Tests comparer-aware creation.
src/​FSharp.Collections.Immutable/​FSharp.Collections.Immutable.fsproj Registers sources and updates package description.
src/​FSharp.Collections.Immutable/​FrozenMap/​FrozenMapTypes.fs Defines the type abbreviation.
src/​FSharp.Collections.Immutable/​FrozenMap/​FrozenMapTuple.fsi Documents reference-tuple APIs.
src/​FSharp.Collections.Immutable/​FrozenMap/​FrozenMapTuple.fs Implements reference-tuple variants.
src/​FSharp.Collections.Immutable/​FrozenMap/​FrozenMapOption.fsi Documents option APIs.
src/​FSharp.Collections.Immutable/​FrozenMap/​FrozenMapOption.fs Implements option variants.
src/​FSharp.Collections.Immutable/​FrozenMap/​FrozenMapExtensions.fsi Declares creation overloads.
src/​FSharp.Collections.Immutable/​FrozenMap/​FrozenMapExtensions.fs Delegates overloads to comparer-aware creation.
src/​FSharp.Collections.Immutable/​FrozenMap/​FrozenMapCE.fs Collects bindings and freezes once.
src/​FSharp.Collections.Immutable/​FrozenMap/​FrozenMap.fsi Defines and documents the public API.
src/​FSharp.Collections.Immutable/​FrozenMap/​FrozenMap.fs Implements reads, transformations, and lookups.
src/​FSharp.Collections.Immutable/​FrozenMap/​CustomComparersFrozenMap.fsi Documents comparer-aware creation.
src/​FSharp.Collections.Immutable/​FrozenMap/​CustomComparersFrozenMap.fs Implements the comparer struct.
src/​FSharp.Collections.Immutable/​ComputationExpressions.fs Exposes frozenMap.
README.md Lists FrozenMap support.
docsSrc/​index.md Adds FrozenMap to the collection table.
docsSrc/​Explanations/​Background.md Lists the new binding and expression.
CHANGELOG.md Records FrozenMap functionality.

Review effort: Balanced

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The broad public API, comparer decisions, and framework-specific alternate lookups warrant final maintainer validation across both target frameworks.

No open issues 🎉

Review effort: Balanced

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The substantial cross-framework API warrants human validation of F# overload resolution and .NET 10 interop; builds and tests were not independently executed.

No open issues 🎉

Review effort: Balanced

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The broad public API, overloaded extensions, and framework-specific lookups warrant human validation without independently verified build and test results.

No open issues 🎉

Review effort: Balanced

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The broad cross-framework API and F#/BCL interoperability need final human validation; builds and tests were not independently verified.

No open issues 🎉

Review effort: Balanced

xperiandri and others added 3 commits October 20, 2026 18:13
… module

Adds `FrozenMap` over `FrozenDictionary<'Key, 'Value>` after ADR 0001
in `FrozenMap/FrozenMap.fsi` and `FrozenMap/FrozenMap.fs`, each function
documented in the signature file.

- The module reads and transforms a map that is built once: it has no
  `add`, `remove`, `change`, `clear`, builder or `with*Comparer*`.
  `choose`, `filter`, `map` and `partition` rebuild the map in O(n)
  with the key comparer of the input; `filter` and `partition` return
  the input itself for a part that keeps every binding.
- The functions with a name of `Map` have its semantics and
  exceptions: `ofArray`, `ofList` and `ofSeq` keep the last binding of
  a key, `find` and `pick` throw the `KeyNotFoundException` of `Map`,
  and `findKey` the one of `Seq.pick`. The wrappers keep the semantics
  of the BCL: `ofKeyValuePairs` and the comparer variants keep the
  first key and the last value of equal keys, as `ToFrozenDictionary`
  does, and `ofSeqBy` rejects every repeated key.
- `keys` and `values` return flat lists over the arrays of the map
  without a copy, and `foldBack` reads those arrays backwards.
  `containsValue` compares with the default comparer of the values,
  because a frozen map has no value comparer.
- On `net10.0`, `alternateLookup` and `tryAlternateLookup` wrap
  `GetAlternateLookup` and `TryGetAlternateLookup`. The type of the
  alternate keys is their first type argument, and their documentation
  points span alternates to the methods of the map.
- `try*` functions return `voption`, tuples are struct tuples, and the
  `Option` and `Tuple` namespaces repeat those functions with options
  and reference tuples.
- `frozenMap { }` collects `yield struct (key, value)` and
  `yield KeyValuePair` in a dictionary with the key comparer and
  freezes it when it runs; `FrozenMapCE` takes a key comparer.
- The package description mentions System.Collections.Frozen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `FrozenMapOps` records the functions of the module, and `FrozenMap`
  derives the reading, transformation and creation facets; a frozen map
  has no mutation facet.
- The tests of `FrozenMap` cover null inputs, the key comparer of the
  creation functions and of the transformations, the results that are
  the input itself, the arrays of the keys and the values, and the
  repeated keys of the functions that wrap `ToFrozenDictionary`.
- On `net10.0`, the alternate lookups find `int64` keys by `int`
  alternates through a comparer that implements
  `IAlternateEqualityComparer<int, int64>`, find nothing for
  `HashIdentity.Structural` and for string keys without a span, and a
  span goes through `GetAlternateLookup` of the map.
- The computation expression and the `Option` and `Tuple` namespaces
  have their own tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README, the index page and the background page list `FrozenMap`
with `frozenMap { }`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants