Modern C# · Lesson 35

FrozenDictionary fixes lookup policy

Freeze a read-heavy map with an explicit comparer and detached scalar values.

The concept and environment

FrozenDictionary is a library collection designed for a map that is built relatively infrequently and read repeatedly. It is available in .NET 9 and is not a C# 14 syntax feature. The sample freezes a small string-to-integer configuration with an explicit ordinal, case-insensitive comparer. That comparer is part of the lookup contract, not a decorative constructor choice. The frozen result exposes a fixed mapping, while the original Dictionary remains a separate mutable builder. Freezing can suit a stable lookup table, but a tiny teaching example does not establish a performance advantage for every workload.

Walk through the portable program

The builder starts with small mapped to two and large mapped to five. ToFrozenDictionary creates the fixed mapping using the chosen comparer. Updating the builder's small entry to nine afterward changes only the builder. Looking up uppercase SMALL in the frozen map still finds two because case-insensitive ordinal equality treats it as the same key. An absent other key returns false from TryGetValue, and the frozen count remains two. The output is frozen:2, source:9, missing:False, and count:2, with no reliance on dictionary enumeration order.

A deeper design boundary

A frozen map fixes its key-to-value associations; it does not recursively freeze referenced values. Integers make this example a straightforward scalar snapshot. If values were mutable lists or configuration objects, those object references could still expose later mutations. Decide whether the application requires immutable values, defensive copies, or carefully controlled ownership before treating a fixed map as a complete immutable object graph. A read-only interface and a genuinely fixed collection also express different guarantees, so inspect the actual producer and retained aliases when reviewing a configuration publication boundary.

Pitfalls and limits

Construction has a cost and duplicate-key behavior must be considered under the selected comparer. Two keys that differ only in case can collide when a case-insensitive mapping is built, even if a previous source treated them as different. Specify which input is valid rather than silently choosing an arbitrary winner. Also avoid repeated freezing inside every request when the intended design is a stable shared table. Lookup speed and construction cost vary by data and runtime; do not transfer a desktop benchmark directly to the bundled browser engine or infer timing from these deterministic values.

Practice one: isolate a contract

First change the builder's large value after freezing and predict that its frozen value stays five. Then add a new key only to the builder and predict that the frozen count remains two and the new lookup fails. Hint: test association independence with separate source operations, without depending on key enumeration order. Add a lookup for a different capitalization of large to verify the comparer policy explicitly. These cases distinguish fixed mapping, source mutation, and key equality instead of allowing one successful lookup to stand for all three contracts.

Practice two: challenge an assumption

For another exercise, build a map whose values are mutable integer lists, freeze it, and append through an originally retained list reference. Predict that the frozen lookup exposes the changed list contents. Hint: the map stores the reference value; it does not clone the list. Then implement a value-copy strategy appropriate to your own configuration and repeat the mutation test. Separately prepare two source keys that collide under OrdinalIgnoreCase and decide whether validation should reject them before freezing. Document that policy rather than accepting an accidental last-writer convention.

Review the resulting contract

Review a frozen lookup table by checking its build frequency, comparer, duplicate-input policy, value ownership, and missing-key behavior. The sample verifies these concerns using scalar entries and TryGetValue while deliberately avoiding implementation-specific ordering and timing claims. In production, a validated immutable value model can make a frozen map a useful publication boundary. If frequent updates are required, choose an update strategy deliberately instead of recreating a costly structure without measuring it. A fixed association table solves one collection problem; it does not automatically solve mutable object graphs or configuration validation.

A working example

using System;
using System.Collections.Generic;
using System.Collections.Frozen;
var source = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
    ["small"] = 2,
    ["large"] = 5
};
var frozen = source.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
source["small"] = 9;
Console.WriteLine($"frozen:{frozen["SMALL"]}");
Console.WriteLine($"source:{source["small"]}");
Console.WriteLine($"missing:{frozen.TryGetValue("other", out _)}");
Console.WriteLine($"count:{frozen.Count}");

Expected output

frozen:2
source:9
missing:False
count:2
Try this
Verify source edits do not alter frozen scalar entries. Test mutable reference values separately; the map does not deep-freeze its values.

Further reading