Sunday, 16 August 2026

Rust - Introduction to lambda style closures

Rust Language Primer - FirstOrDefault with Lambda style closures

If you're a C# developer exploring Rust, you’ll quickly notice that many concepts feel familiar — especially when working with collections, generics, and filtering values using lambda‑like syntax. In C#, you might write:

v.FirstOrDefault(x => x % 2 == 0);

I Rust, this is similar, note that we do not use extension methods like in C# and LINQ.

|x| x % 2 == 0

This article walks through how to build Rust equivalents of FirstOrDefault, including default values, nullable handling, and predicate‑based filtering. Along the way, you’ll see how Rust’s Option<T>, generics, and closures map nicely to concepts you already know from C#.

🧠 Rust Closures: The |x| Syntax

Rust closures use vertical bars to define parameters: C#: x => x % 2 == 0 Rust: |x| x % 2 == 0 |x| which for many who are familiar to some entry level Calculus reads like 'absolute' value - this is instead the 'x goes to' operator in Rust! Closures in Rust are strongly typed, safe, and have zero runtime overhead thanks to monomorphization.

🛠️ Implementing FirstOrDefault in Rust

The code shown in this article will show three variants
  • 1. A default‑based version (T::default())
  • 2. A nullable version using Option<T>
  • 3. A predicate‑based version similar to C# lambdas
  • Information: Each method is shown first, followed by usage examples.
📌 First variant - 1. first_or_default — Using T::default() This version returns the first element or a default value (like 0 for i32). It maps good to types like non-nullable integers and other numeric numbers as mentioned i32 but many other scenarios too.

/// Returns the first element of the slice, or T::default() if the slice is empty.
fn first_or_default<T: Default + Clone>(slice: &[T]) -> T {
	slice.first().cloned().unwrap_or_default()
}

📌 Second variant - 2. first_or_defaultv2 — Nullable Version Using Option<T> Rust does not have nulls — instead, it uses Option<T>. (Similar to Monad Option seen in Functional Programming)

/// Returns the first element wrapped in Option<T>, or None if empty.
fn first_or_defaultv2<T: Clone>(slice: &[T]) -> Option<T> {
	slice.first().cloned()   //notice the simplified syntax where T does not have to include Default trait and the omitting of unwrap_or_default()
}

This is the closest Rust equivalent to nullable types in C#. 📌 Third variant 3. first_or_default_where — Predicate‑Based Filtering Let's look at adding a predicate (filter) capability too for FirstOrDefault method. This is the Rust equivalent of: v.FirstOrDefault(x => x % 2 == 0);

/// Returns the first element matching the predicate, or None if none match.
fn first_or_default_where<T, F>(slice: &[T], predicate: F) -> Option<T>
where
T: Clone,
F: Fn(&T) -> bool,
{
    if slice.is_empty() {
        return None;
    }
	slice.iter().find(|x| predicate(x)).cloned()   // |x| is to be read as 'x goes to' similar to C# 'x => '
}

Notice the closure syntax: |x| predicate(x) — just like C# lambdas, but with pipes instead of parentheses.

🧪 Example Usage

Below is a complete example showing how these functions behave with vectors, empty slices, nullable values, and predicate filtering.

fn main() {

// A normal vector of i32 values
let v = vec![1, 2, 3];

// Uses T::default() (0 for i32) when the slice is empty
let first_number_nonempty_vector = first_or_default(&v);
println!("First number: {:?}", first_number_nonempty_vector);

// Explicitly typed empty vector
let empty: Vec<i32> = vec![];
let first_number_empty_vector = first_or_default(&empty);
println!("First number of empty vector: {:?} {:?}", empty, first_number_empty_vector);

// Nullable version: returns Option<T>
let first_number_empty_vector_nullable = first_or_defaultv2(&empty);
println!(
"First number (nullable) of empty vector {:?}: {:?}  . - Is the result None? : {}",
empty,
first_number_empty_vector_nullable,
first_number_empty_vector_nullable == None
);

// Another vector for predicate-based filtering
let s = vec![11, 3, 7, 13, 128, 5];

// Finds the first even number using a closure (lambda)
let first_even_number_nonempty_vector = first_or_default_where(&s, |x| x % 2 == 0);
println!("First event number of non-empty vector {:?}: {:?}", s, first_even_number_nonempty_vector);

// unwrap_or_else provides a safe fallback (0) if None
println!(
"First event number of non-empty vector {:?}: {:?}",
s,
first_even_number_nonempty_vector.unwrap_or_else(|| 0)
);
}

📤 Output

First number: 1
First number of empty vector: [] 0
First number (nullable) of empty vector []: None  . - Is the result None? : true
First event number of non-empty vector [11, 3, 7, 13, 128, 5]: Some(128)
First event number of non-empty vector [11, 3, 7, 13, 128, 5]: 128



🎯 Final Thoughts

Rust may look different at first, but if you're coming from C#, you’ll quickly recognize familiar patterns:
  • Option<T> behaves like nullable types
  • |x| closures behave like C# lambdas
  • Generics are powerful and zero‑cost
  • unwrap_or_default() and unwrap_or_else() feel like C#’s ?? operator or GetValueOrDefault()
  • Rust gives you the same expressive power — but with stronger safety guarantees and no runtime overhead.
If you're a C# developer exploring Rust, trying to implement C# Linq methods is a good start to familiarize yourself with the syntax. Happy (Rust) coding! 🦀

Saturday, 1 August 2026

Kartverket Matrikkel Eiendoms API with MAUI

This article will show how looking up data from Eiendoms API (v1) from Kartverket. The data is retrieved from Matrikkelen, the official registry of properties and land parcels in Norway. The demo will show using a MAUI app and the source code with the demo is available in my Github repository to be cloned from here :


Screenshots

Clicking on a property in the map will show the matrikkelen information for the first hit of the properties around the clicked point.

Using Kartverket's Eiendom API v1

The Eiendom API offers basic Eiendomsinformasjon, property information. A Swagger page and the endpoint of the API is available here:

The service, that is a client to contact and retrieved the Eiendomsinformation for a given point that uses the /punkt API method is shown below. Please note that the json naming dictates we must set the Property naming policy to camelCase, as we can see from the JsonProperty naming convention used further down in the Json POCO classes shown in KartverketResponse.cs code snippet.

KartverketService.cs


using System.Globalization;
using System.Net.Http.Json;
using System.Text.Json;

namespace MauiMapAppDemo.Services
{

    public class KartverketService
    {

        private const string _apiKartverketEiendomV1BaseUrl = "https://api.kartverket.no/eiendom/v1/";

        private readonly HttpClient _httpClient = new HttpClient
        {
            BaseAddress = new Uri(_apiKartverketEiendomV1BaseUrl)
        };

        /// <summary>
        /// Retrieves matrikkel informasjon from given point (Punkt) from Kartverket's Eiendom API v1
        /// </summary>
        /// <param name="latitude">Latitude</param>
        /// <param name="longitude">Longitude</param>
        /// <param name="koordSys">Defaulting here to EUREF89 = 4258 as the coordinate system id, which Kartverket uses and is also what Google maps coords are using</param>
        /// <returns></returns>
        public async Task<KartverketPunktResponse?> GetMatrikkelInformationFromLocationAsync(double latitude, double longitude, int koordSys = 4258)
        {
            string url = $"punkt?ost={longitude.ToString(CultureInfo.InvariantCulture)}&nord={latitude.ToString(CultureInfo.InvariantCulture)}&koordsys={koordSys}&radius=10&utkoordsys={koordSys}&treffPerSide=1&side=1";

            var kartverketResponseForLocation = await _httpClient.GetFromJsonAsync<KartverketPunktResponse>(url, options: new System.Text.Json.JsonSerializerOptions
            {
                PropertyNamingPolicy = JsonNamingPolicy.CamelCase
            });
            return kartverketResponseForLocation;
        }

    }
}

About the choice of EUREF 89 = 4258 as the default coordinate system in the call above. Google maps uses actually WGS 84 which is EPSG coordinate system id 4326, but the difference is less than a meter. Since a 10 meter radius of the clicked point is used, we pass in the clicked Location point's latitude and longitude to the north and east parameters in the url call above in the KartverketService.cs sample code shown, with a given radius of 10 and although we get a result of multiple hits within the 10 meter radius, the first if any of properties or parcels found is returned from the service, by setting the side and treffPerSide parameters.

🌍 What are EUREF89 and EPSG?

🗺️ EUREF89 is a geographic coordinate reference system used across Europe, including by Kartverket in Norway. It defines how latitude and longitude positions are represented on the Earth's surface.

🔢 EPSG:4258 is the unique identifier for the EUREF89 coordinate system. EPSG codes are standardized reference numbers that mapping software, APIs, and GIS tools use to identify coordinate systems.

📍 In this project, coordinates are obtained from Google Maps and sent to Kartverket's Eiendom API v1 using EUREF89 (EPSG:4258). Using the same coordinate reference system helps ensure accurate property lookups in the Norwegian Matrikkel.

🏠 The result is that a location selected on the map can be translated directly into property and cadastral information from Kartverket.

The following Json classes will map the returned Json data from the service to objects. To create these classes, I just copied example output response from the API method /punkt and used Paste Special => Paste Json as classes in VS 2026.

KartverketPunktResponse.cs


using System.Text;
using System.Text.Json.Serialization;

namespace MauiMapAppDemo.Services
{
    public class KartverketPunktResponse
    {
        [JsonPropertyName("eiendom")]
        public List<Punkt>? Eiendom { get; set; }

        [JsonPropertyName("metadata")]
        public Metadata? Metadata { get; set; }
    }

    public class Metadata
    {
        [JsonPropertyName("side")]
        public int Side { get; set; }

        [JsonPropertyName("sokeStreng")]
        public string? SokeStreng { get; set; }

        [JsonPropertyName("totaltAntallTreff")]
        public int TotaltAntallTreff { get; set; }

        [JsonPropertyName("treffPerSide")]
        public int TreffPerSide { get; set; }

        [JsonPropertyName("viserFra")]
        public int ViserFra { get; set; }

        [JsonPropertyName("viserTil")]
        public int ViserTil { get; set; }
    }

    public class Punkt
    {
        [JsonPropertyName("bruksnummer")]
        public int Bruksnummer { get; set; }

        [JsonPropertyName("festenummer")]
        public int Festenummer { get; set; }

        [JsonPropertyName("gardsnummer")]
        public int Gardsnummer { get; set; }

        [JsonPropertyName("hovedområde")]
        public bool HovedOmrade { get; set; }

        [JsonPropertyName("kommunenummer")]
        public string? Kommunenummer { get; set; }

        [JsonPropertyName("lokalid")]
        public int Lokalid { get; set; }

        [JsonPropertyName("matrikkelnummertekst")]
        public string? Matrikkelnummertekst { get; set; }

        [JsonPropertyName("meterFraPunkt")]
        public int MeterFraPunkt { get; set; }

        [JsonPropertyName("nøyaktighetsklasseteig")]
        public string? Noyaktighetsklasseteig { get; set; }

        [JsonPropertyName("objekttype")]
        public string? Objekttype { get; set; }

        [JsonPropertyName("oppdateringsdato")]
        public DateTime? Oppdateringsdato { get; set; }

        [JsonPropertyName("representasjonspunkt")]
        public Representasjonspunkt? Representasjonspunkt { get; set; }

        [JsonPropertyName("seksjonsnummer")]
        public int Seksjonsnummer { get; set; }

        [JsonPropertyName("teigmedflerematrikkelenheter")]
        public bool Teigmedflerematrikkelenheter { get; set; }

        [JsonPropertyName("uregistrertjordsameie")]
        public bool Uregistrertjordsameie { get; set; }

        public string FullstendigMatrikkelNummer
        {
            get
            {
                var sb = new StringBuilder();
                if (!string.IsNullOrWhiteSpace(Kommunenummer))
                {
                    sb.Append(Kommunenummer?.ToString().PadLeft(4, '0'));
                }
                if (Gardsnummer > 0)
                {
                    sb.Append("/" + Gardsnummer);
                }
                if (Bruksnummer > 0)
                {
                    sb.Append("/" + Bruksnummer);
                }
                if (Festenummer > 0)
                {
                    sb.Append("/" + Festenummer);
                }
                if (Seksjonsnummer > 0)
                {
                    sb.Append("/" + Seksjonsnummer);
                }

                return sb.ToString();
            }
        }
    }

    public class Representasjonspunkt
    {
        [JsonPropertyName("koordsys")]
        public int Koordsys { get; set; }

        [JsonPropertyName("nord")]
        public double Nord { get; set; }

        [JsonPropertyName("øst")]
        public double Ost { get; set; }
    }

}



Groundwork on the parts of property information for Fullstendig matrikkelnummer

🏠 Components of a Full Matrikkel Number

Icon Component Abbreviation Example Description
🏛️ Municipality Number Knr. 5001 Identifies the municipality where the property is located. An overview of Kommunenummer can be seen here : https://www.kartverket.no/til-lands/fakta-om-norge/norske-fylke-og-kommunar
🌾 Farm Number - Gårdnummer / Gardsnummer Gnr. 138 Identifies the original farm or cadastral area within the municipality.
🏠 Usage Number / Bruksnummer Bnr. 4515 Identifies a specific property or parcel within a farm number.
📜 Leasehold Number / Festenummer Fnr. 7 Used when the property is a leased parcel of land (festetomt). The festetomt / lease agreements typically lasts very long, in the area of 80-100 years typically and can be renewed.
🏢 Section Number Snr. 12 Identifies a specific section or apartment within a sectioned property.

📋 Example

5001 / 138 / 4515 / 7 / 12

Note that the first three numbers are ALWAYS set for all properties in Norway. That is Kommunenummer, Gårdsnummer (Gardsnummer) and Bruksnummer. And some properties also are leased, so they got a lease number or sectioned too. The numbers are read left to right in descending order of the overview shown above.
Value Meaning
🏛️ 5001 Municipality Number
🌾 138 Farm Number (Gårdsnummer)
🏠 4515 Usage Number (Bruksnummer)
📜 7 Leasehold Number (Festenummer)
🏢 12 Section Number (Seksjonsnummer)

💡 A full matrikkel number is built in the following order:

🏛️ Municipality Number → 🌾 Farm Number → 🏠 Usage Number → (📜 Leasehold Number → 🏢 Section Number )



📐 Getting Property Boundary Area Information (Grenseinformasjon)

You can retrieve boundary information for a selected property using the /punkt/omrader endpoint in Eiendoms API (v1).

The API returns a GeoJSON geometry describing the property's boundaries. The coordinates are provided as latitude and longitude values, so they must be projected into a metric coordinate system before calculating the property's area.

🗺️ In a GeoJSON polygon, the coordinates are returned as a multi-dimensional array:

  • ✅ The first array represents the property's outer boundary.
  • ➖ Any additional arrays represent holes or excluded areas within the property.

📏 To calculate the total area:

  1. Calculate the area of the outer boundary polygon.
  2. Subtract the area of each interior polygon (hole).

💡 This follows the standard GeoJSON polygon specification and ensures that any gaps within the property are excluded from the final area calculation.

The following screenshots shows selecting a property for testing out the precision of the area calculations using of a polygon of property's boundary using Eiendoms API v1.
As the screenshots show, the demo shows an agreement with Trondheimskart solution, which is the property map that Trondheim kommune is using : The calculated area in Trondheimskartet is 1125 square metres and the app calculate it to 1126 square metres.

🗺️ Area and polygon overview

This note explains how the app turns Kartverket omrade data into a visible polygon and a simple area summary. The goal is to keep the UI readable while still showing the geometry work behind the scenes.

The flow is: tap a point, fetch Kartverket data, build a closed polygon from the returned coordinates, and show the area in square metres. The main page binds the polygon path and the area label separately so the overlay and the text can update independently.

📐 Area calculation

The shared geometry helper uses the shoelace formula. If the incoming ring is still in WGS84 degrees, it first transforms the coordinates to UTM Zone 33N so the result comes out in square metres. The shoelace formulae is also known as Gauss area formula and is visually described with an example here from University of Waterloo in Canada great article:

Note that the code below uses Nuget package ProjNet
using ProjNet.CoordinateSystems;
using ProjNet.CoordinateSystems.Transformations;

namespace MauiMapAppDemo.Services
{
    public static class GeometryUtils
    {
        public static double CalculatePolygonArea(double[][] ring, bool transformToMetricCoordinateSystem = true)
        {
            if (ring == null || ring.Length < 3)
            {
                throw new ArgumentException("Polygon must contain at least three points.", nameof(ring));
            }

            var projectedRing = new double[ring.Length][];

            if (transformToMetricCoordinateSystem)
            {
                var ctFactory = new CoordinateTransformationFactory();
                var wgs84 = GeographicCoordinateSystem.WGS84;
                var utm33 = ProjectedCoordinateSystem.WGS84_UTM(33, true);
                var transform = ctFactory.CreateFromCoordinateSystems(wgs84, utm33);

                for (int i = 0; i < ring.Length; i++)
                {
                    var projected = transform.MathTransform.Transform(ring[i][0], ring[i][1]);
                    projectedRing[i] = new[] { projected.x, projected.y };
                }
            }

            double area = 0;

            for (int i = 0; i < projectedRing.Length; i++)
            {
                int next = (i + 1) % projectedRing.Length;
                area += projectedRing[i][0] * projectedRing[next][1];
                area -= projectedRing[next][0] * projectedRing[i][1];
            }

            return Math.Abs(area) / 2.0;
        }

        public static double CalculateTotalArea(double[][][] coordinates, bool transformToMetricCoordinateSystem = true)
        {
            if (coordinates == null || coordinates.Length == 0)
            {
                return 0;
            }

            double totalArea = 0;

            for (int ringIndex = 0; ringIndex < coordinates.Length; ringIndex++)
            {
                var ringArea = CalculatePolygonArea(coordinates[ringIndex], transformToMetricCoordinateSystem);

                if (ringIndex == 0)
                {
                    totalArea += ringArea;
                }
                else
                {
                    totalArea -= ringArea;
                }
            }

            return totalArea;
        }
    }
}

For GeoJSON, the first ring is the outer boundary and later rings are holes. That matches how Kartverket polygons are usually interpreted when the app calculates total area from an omrade response.

🛰️ Kartverket omrade fetch

The service builds the omrader URL from the clicked latitude and longitude, then deserializes the response with a shared camel-case serializer option. The resulting geometry is what the app later converts into a polygon overlay.

public async Task<KartverketOmraadeResponse?> GetGeoJsonFromLocationAsync(double latitude, double longitude, int koordSys = 4258)
{
    string url = $"punkt/omrader?ost={longitude.ToString(CultureInfo.InvariantCulture)}&nord={latitude.ToString(CultureInfo.InvariantCulture)}&koordsys={koordSys}&radius=10&utkoordsys={koordSys}&treffPerSide=1&side=1";

    var kartverketResponseForLocation = await _httpClient.GetFromJsonAsync<KartverketOmraadeResponse>(url, options: s_camelCaseJsonOptions);
    return kartverketResponseForLocation;
}

🧾 Kartverket omrade response model

The response model is where the geometry turns into a calculated area value. The top-level object exposes features, and the area is derived from the nested polygon coordinates through a helper property.

public class KartverketOmraadeResponse
{
    [JsonPropertyName("features")]
    public Feature[] Features { get; set; }

    [JsonPropertyName("type")]
    public string Type { get; set; }

    public double? TotalAreaOfAllAreas
    {
        get
        {
            try
            {
                double totalArea = Features.Sum(f => GeometryUtils.CalculateTotalArea(f.Geometry.Coordinates));
                return totalArea;
            }
            catch (Exception)
            {
                return null;
            }
        }
    }
}

public class Geometry
{
    [JsonPropertyName("coordinates")]
    public double[][][] Coordinates { get; set; }

    [JsonPropertyName("type")]
    public string Type { get; set; }

    public double? TotalArea
    {
        get
        {
            try
            {
                double totalArea = GeometryUtils.CalculateTotalArea(Coordinates);
                return totalArea;
            }
            catch (Exception)
            {
                return null;
            }
        }
    }
}

public class Feature
{
    [JsonPropertyName("geometry")]
    public Geometry Geometry { get; set; }

    [JsonPropertyName("properties")]
    public Properties Properties { get; set; }

    [JsonPropertyName("type")]
    public string Type { get; set; }
}

The nice part here is that the area calculation is pushed into the model layer through read-only properties. That makes the popup logic simpler because it can ask for the total area instead of re-running geometry math in the UI layer. 📦

🧩 Polygon refresh behavior

The map behavior listens to the bound polygon path and rebuilds the overlay whenever the path changes. It clears the old polygon first, then adds the latest path back to the map.

private void RefreshMatrikkelPolygon()
{
    if (_map?.MapElements == null)
    {
        return;
    }

    ClearMatrikkelPolygon();

    if (!IsMatrikkelMode || !MatrikkelPolygonPath.Any())
    {
        return;
    }

    var polygon = new Polygon
    {
        StrokeColor = Colors.Red,
        StrokeWidth = 5,
        FillColor = Color.FromArgb("#22FF0000")
    };

    foreach (var location in MatrikkelPolygonPath)
    {
        polygon.Geopath.Add(location);
    }

    if (polygon.Geopath.Count < 3)
    {
        return;
    }

    var firstLocation = polygon.Geopath.First();
    var lastLocation = polygon.Geopath.Last();

    if (firstLocation.Latitude != lastLocation.Latitude || firstLocation.Longitude != lastLocation.Longitude)
    {
        polygon.Geopath.Add(firstLocation);
    }

    _matrikkelPolygon = polygon;
    _map.MapElements.Add(polygon);
}

That final closure step matters. If the last point does not match the first point, the behavior appends the first point again so the polygon is visually closed and the shape is easier to reason about. 🔁

📊 Area presentation

The viewmodel keeps the user-facing area text separate from the map geometry. When Kartverket returns a usable polygon, the app formats the result as square metres and also shows the same value in mål.

if (omraadeResponse?.TotalAreaOfAllAreas is double totalArea && totalArea > 0)
{
    MatrikkelAreaText = FormatMatrikkelAreaText(totalArea);
}
else
{
    MatrikkelAreaText = "Areal: ukjent";
}

private static string FormatMatrikkelAreaText(double areaSquareMetres)
{
    var maal = areaSquareMetres / 1000d;
    return $"Areal: {areaSquareMetres:N0} m² ({maal:N2} mål)";
}

The bound properties behind that flow are the polygon path and the area label. One drives the overlay, the other is what the user sees on the page.

public IEnumerable<Location> MatrikkelPolygonPath
{
    get => (IEnumerable<Location>)GetValue(MatrikkelPolygonPathProperty);
    set => SetValue(MatrikkelPolygonPathProperty, value);
}

public static readonly BindableProperty MatrikkelPolygonPathProperty =
    BindableProperty.Create(
        nameof(MatrikkelPolygonPath),
        typeof(Location[]),
        typeof(MapPinsBehavior),
        defaultValue: Array.Empty<Location>(),
        propertyChanged: OnMatrikkelPolygonPathChanged);

public static readonly BindableProperty IsMatrikkelModeProperty =
    BindableProperty.Create(
        nameof(IsMatrikkelMode),
        typeof(bool),
        typeof(MapPinsBehavior),
        false);

📱 Map page wiring

The map page binds the behavior directly. In this project the page is MapsDemo.xaml, and the snippet below shows the same behavior wiring that drives the map, measurement mode, and matrikkel polygon overlay.

<maps:Map Grid.Row="3"
          Margin="12"
          x:Name="MapCtrl"
          MapType="Street">
    <maps:Map.Behaviors>
        <behaviors:MapPinsBehavior
            BindingContext="{Binding Source={x:Reference MapCtrl}, Path=BindingContext}"
            Center="{Binding MapCenter}"
            PinItems="{Binding CabinPins}"
            IsMeasuringMode="{Binding IsMeasuringMode}"
            IsMatrikkelMode="{Binding IsMatrikkelMode}"
            MeasureStart="{Binding FirstLocationMeasureMode}"
            MatrikkelPolygonPath="{Binding MatrikkelPolygonPath}"
            MeasureEnd="{Binding SecondLocationMeasureMode}"
            MapClickedCommand="{Binding MapClickedCommand}"
            PinClickedCommand="{Binding PinClickedCommand}" />
    </maps:Map.Behaviors>
</maps:Map>

✨ Short takeaway

The app gets geometry from Kartverket, calculates area in a metric-friendly way, closes the polygon before drawing it, and keeps the user-facing square-metre text separate from the map overlay. That makes the map interaction clearer and easier to explain in a blog post.

Sunday, 12 July 2026

Android and Google Maps using .NET Maui

MAUI Maps Demo Notes 🗺️

A short summary of the map demo, measurement mode, and pin icon adjustments.
The source code can be cloned from this Github repo of mine : 👉 MauiMapAppDemo Github repo 💾

This demo shows a .NET MAUI map page with two main ideas: regular cabin pins and a measure mode that lets the user tap two points to draw a red line and calculate distance. The page is driven by a view model, while a map behavior keeps the map logic reusable and keeps the XAML clean. In use is also CommunityToolkit.Mvvm to make MVVM easier to implement. The use of Behaviors to implement view specific behavior / functionality is a common pattern when working with client based logic and MVVM and UI controls.

🔒 The app reads Google Maps and Azure Maps keys from user secrets during local development. The keys are not exposed in the solution or this article.

What the solution does

  • Loads cabin pin data from the view model and inits the location around Trondheim in Norway.
  • Supports a measure mode that tracks start and end taps (by clicking a button to activate this)
  • Draws a polyline (line segment) between the two points when both locations are set.
  • Uses custom measurement marker icons so the start and end pins are easier to spot.
  • Keeps the layout polished with spacing around the logo, button, and map.

Behavior

The behavior listens for map clicks, manages the temporary measure pins, and clears the graphics when measure mode is turned off. It also wires the pin click command for normal cabin markers.

using MauiMapAppDemo.ViewModels;
using Microsoft.Maui.Controls.Maps;
using Microsoft.Maui.Maps;
using System.Windows.Input;

namespace MauiMapAppDemo.Behaviors
{
    public class MapPinsBehavior : Behavior<Microsoft.Maui.Controls.Maps.Map>
    {
        public static readonly BindableProperty IsMeasuringModeProperty =
            BindableProperty.Create(
                nameof(IsMeasuringMode),
                typeof(bool),
                typeof(MapPinsBehavior),
                false,
                propertyChanged: OnMeasurementStateChanged);

        private void RefreshMeasurementLine()
        {
            if (_map == null)
            {
                return;
            }

            ClearMeasurementGraphics();

            if (MeasureStart == null || MeasureEnd == null)
            {
                return;
            }

            _startPin = new MeasurementPin
            {
                Label = "Start",
                Address = "Measurement Start",
                Location = MeasureStart,
                IconResourceName = "startmarkerv2"
            };

            _endPin = new MeasurementPin
            {
                Label = "End",
                Address = "Measurement End",
                Location = MeasureEnd,
                IconResourceName = "endmarkerv2"
            };
        }
    }
}

View model

The view model owns the state: measurement mode, the first and second tapped locations, the computed distance, and the cabin pins. That keeps the behavior focused on map rendering and interaction.

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;

namespace MauiMapAppDemo.ViewModels
{
    public partial class MapsViewModel : ObservableObject
    {
        [ObservableProperty]
        public bool _isMeasuringMode;

        [ObservableProperty]
        private Location? _firstLocationMeasureMode;

        [ObservableProperty]
        private Location? _secondLocationMeasureMode;

        [ObservableProperty]
        private double _distanceMeasuredKm;

        public ObservableCollection<MapPinModel> CabinPins { get; } = [];

        [RelayCommand]
        private async Task MapClicked(Location location)
        {
            if (IsMeasuringMode)
            {
                HandleMeasuringMode(location);
                return;
            }

            await HandleDefaultMapClicked(location);
        }
    }
}

Marker icons 📍

The biggest visual improvement was the measurement markers. The solution now uses small transparent SVG assets for the start and end points, which avoids oversized default-looking icons and keeps the map readable.

The important part was keeping those marker assets compact and making sure the Android handler only applies them to the measurement pins.

Layout cleanup

The page also got a small spacing pass: the button gained more breathing room and a larger corner radius, the logo and map got margins, and the Eudem-25 info label was moved higher so the top of the page reads more naturally.

MAUI Maps Demo - Code Details

This is a compact code detail listing for the two core pieces behind the demo: the map behavior and the view model. It is meant as a quick reference for how the map, measurement flow, and pin handling fit together.

MapPinsBehavior

The behavior owns map interaction, pin rendering, measurement graphics, and the custom marker icon hookup used for the start and end measurement pins.

using MauiMapAppDemo.ViewModels;
using Microsoft.Maui.Controls.Maps;
using Microsoft.Maui.Maps;
using System.Windows.Input;

namespace MauiMapAppDemo.Behaviors
{

    public class MapPinsBehavior : Behavior<Microsoft.Maui.Controls.Maps.Map>
    {

        private Microsoft.Maui.Controls.Maps.Map? _map;

        private Microsoft.Maui.Controls.Maps.Polyline? _measurementLine;

        private Microsoft.Maui.Controls.Maps.Pin? _startPin;
        private Microsoft.Maui.Controls.Maps.Pin? _endPin;


        public static readonly BindableProperty IsMeasuringModeProperty =
            BindableProperty.Create(
                nameof(IsMeasuringMode),
                typeof(bool),
                typeof(MapPinsBehavior),
                false,
                propertyChanged: OnMeasurementStateChanged);

        public bool IsMeasuringMode
        {
            get => (bool)GetValue(IsMeasuringModeProperty);
            set => SetValue(IsMeasuringModeProperty, value);
        }


        public static readonly BindableProperty MeasureStartProperty =
            BindableProperty.Create(
                nameof(MeasureStart),
                typeof(Location),
                typeof(MapPinsBehavior),
                propertyChanged: OnMeasurementChanged
            );

        public Location MeasureStart
        {
            get => (Location)GetValue(MeasureStartProperty);
            set => SetValue(MeasureStartProperty, value);
        }

        public static readonly BindableProperty MeasureEndProperty =
            BindableProperty.Create(
                nameof(MeasureEnd),
                typeof(Location),
                typeof(MapPinsBehavior),
                propertyChanged: OnMeasurementChanged
            );

        public Location MeasureEnd
        {
            get => (Location)GetValue(MeasureEndProperty);
            set => SetValue(MeasureEndProperty, value);
        }


        public static readonly BindableProperty CenterProperty =
         BindableProperty.Create(
             nameof(Center),
             typeof(Location),
             typeof(MapPinsBehavior),
             propertyChanged: OnCenterChanged
             );

        public Location Center
        {
            get => (Location)GetValue(CenterProperty);
            set => SetValue(CenterProperty, value);
        }

        public static readonly BindableProperty PinItemsProperty =
            BindableProperty.Create(
                nameof(PinItems),
                typeof(IEnumerable<MapPinModel>),
                typeof(MapPinsBehavior),
                propertyChanged: OnPinItemsChanged
                );      

        public IEnumerable<MapPinModel>? PinItems
        {
            get => (IEnumerable<MapPinModel>?)GetValue(PinItemsProperty);
            set => SetValue(PinItemsProperty, value);
        }

        public static readonly BindableProperty MapClickedCommandProperty =
            BindableProperty.Create(
                nameof(MapClickedCommand),
                typeof(ICommand),
                typeof(MapPinsBehavior),
                defaultValue: null);

        public ICommand MapClickedCommand
        {
            get => (ICommand)GetValue(MapClickedCommandProperty);
            set => SetValue(MapClickedCommandProperty, value);
        }

        public static readonly BindableProperty PinClickedCommandProperty =
           BindableProperty.Create(
               nameof(PinClickedCommand),
               typeof(ICommand),
               typeof(MapPinsBehavior),
               defaultValue: null);

        public ICommand PinClickedCommand
        {
            get => (ICommand)GetValue(PinClickedCommandProperty);
            set => SetValue(PinClickedCommandProperty, value);
        }

        protected override void OnAttachedTo(Microsoft.Maui.Controls.Maps.Map bindable)
        {
            _map = bindable;

            WireUpMapClickedCommand(bindable);

            base.OnAttachedTo(bindable);

            RefreshPins();

            if (Center is not null)
            {
                _map.MoveToRegion(MapSpan.FromCenterAndRadius(Center, Distance.FromKilometers(8)));
            }
        }

        private void WireUpMapClickedCommand(Microsoft.Maui.Controls.Maps.Map map)
        {
            map.MapClicked += (object? sender, MapClickedEventArgs e) =>
            {
                if (MapClickedCommand?.CanExecute(e.Location) == true)
                {
                    MapClickedCommand.Execute(e.Location);
                }
            };
        }

        protected override void OnDetachingFrom(Microsoft.Maui.Controls.Maps.Map bindable)
        {
            _map = null;

            base.OnDetachingFrom(bindable);
        }

        private static void OnCenterChanged(
            BindableObject bindable,
            object oldValue,
            object newValue)
        {
            var behavior = (MapPinsBehavior)bindable;

            if (behavior._map is not null && newValue is Location location)
            {
                behavior._map.MoveToRegion(MapSpan.FromCenterAndRadius(location,
                    Distance.FromKilometers(8)));
            }
        }

        private static void OnMeasurementChanged(
            BindableObject bindable,
            object oldValue,
            object newValue)
        {
            ((MapPinsBehavior)bindable).RefreshMeasurementLine();
        }


        private static void OnMeasurementStateChanged(
            BindableObject bindable,
            object oldValue,
            object newValue)
        {
            var behavior = (MapPinsBehavior)bindable;

            if (!(bool)newValue)
            {
                behavior.ClearMeasurementGraphics();
            }
        }


        private void RefreshMeasurementLine()
        {
            if (_map == null)
            {
                return;
            }

            ClearMeasurementGraphics();

            if (MeasureStart == null)
            {
                return;
            }
            else
            {
                _startPin = new MeasurementPin
                {
                    Label = "Start",
                    Address = "Measurement Start",
                    Location = MeasureStart,
                    IconResourceName = "startmarkerv2"
                };

                _map.Pins.Add(_startPin);
            }

            if (MeasureEnd == null)
            {
                return;
            }
            else
            {


                _endPin = new MeasurementPin
                {
                    Label = "End",
                    Address = "Measurement End",
                    Location = MeasureEnd,
                    IconResourceName = "endmarkerv2"
                };

                _map.Pins.Add(_endPin);
            }

            _measurementLine = new Polyline
            {
                StrokeColor = Colors.Red,
                StrokeWidth = 5
            };

            _measurementLine.Geopath.Add(MeasureStart);
            _measurementLine.Geopath.Add(MeasureEnd);

            _map.MapElements.Add(_measurementLine);
        }

        private static void OnPinItemsChanged(
            BindableObject bindable,
            object oldValue,
            object newValue)
        {
            ((MapPinsBehavior)bindable).RefreshPins();
        }

        private void ClearMeasurementGraphics()
        {
            if (_map == null)
            {
                return;
            }

            if (_startPin != null)
            {
                _map.Pins.Remove(_startPin);
            }

            if (_endPin != null)
            {
                _map.Pins.Remove(_endPin);
            }

            if (_measurementLine != null)
            {
                _map.MapElements.Remove(_measurementLine);
            }

            _startPin = null;
            _endPin = null;
            _measurementLine = null;
        }

        private void RefreshPins()
        {
            if (_map is null || PinItems is null)
                return;

            _map.Pins.Clear();

            foreach (var item in PinItems.OfType<MapPinModel>())
            {
                var pin = new Pin
                {
                    Label = item.Label,
                    Address = item.Address,
                    Location = new Location(
                        item.Latitude,
                        item.Longitude)
                };

                pin.MarkerClicked += (_, _) =>
                {
                    if (PinClickedCommand?.CanExecute(item) == true)
                    {
                        PinClickedCommand.Execute(item);
                    }
                };

                _map.Pins.Add(pin);
            }
        }




    }
}

MapsViewModel

The view model owns the measurement state, the list of cabin pins, and the commands invoked by the page and the behavior.

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MauiMapAppDemo.Repositories.PinLocations;
using MauiMapAppDemo.Services;
using System.Collections.ObjectModel;

namespace MauiMapAppDemo.ViewModels
{

    public partial class MapsViewModel : ObservableObject
    {
        private readonly OpenTopoService _openTopoService;
        private readonly GeocodingService _geocodingService;
        private readonly DialogService _dialogService;

        private bool _pinClickInProgress = false;

        [ObservableProperty]
        public bool _isMeasuringMode;

        [ObservableProperty]
        private Location? _firstLocationMeasureMode;

        [ObservableProperty]
        private Location? _secondLocationMeasureMode;

        [ObservableProperty]
        private double _distanceMeasuredKm;

        public ObservableCollection<MapPinModel> CabinPins { get; } = [];

        public Location MapCenter { get; } = new(63.4305, 10.3951);

        public MapsViewModel(OpenTopoService openTopoService, GeocodingService geocodingService, DialogService dialogService)
        {
            InitCabinPins();

            _openTopoService = openTopoService;
            _geocodingService = geocodingService;
            _dialogService = dialogService;
        }

        [RelayCommand]
        private async Task PinClicked(MapPinModel pin)
        {
            _pinClickInProgress = true;
            try
            {
                var elevation =
                    await _openTopoService.GetElevationAsync(
                        pin.Latitude,
                        pin.Longitude);

                var placementInfo =
                    await _geocodingService.GetGeocodingPlacemark(
                        pin.Latitude,
                        pin.Longitude);

                await _dialogService.ShowAlertAsync(
                    pin.Label,
                    $"{pin.Address}\n\nElevation: {elevation}m\n\n{placementInfo}",
                    "OK");
            }
            finally
            {
                _pinClickInProgress = false;
            }
        }

        [RelayCommand]
        private async Task ToggleMeasureMode()
        {
            IsMeasuringMode = !IsMeasuringMode; //toggle the measuring mode
        }

        [RelayCommand]
        private async Task MapClicked(Location location)
        {
            if (_pinClickInProgress)
            {
                return;
            }

            if (IsMeasuringMode)
            {
                HandleMeasuringMode(location);
                return;
            }

            await HandleDefaultMapClicked(location);
        }

        private void HandleMeasuringMode(Location location)
        {
            if (FirstLocationMeasureMode == null)
            {
                FirstLocationMeasureMode = location;
                return;
            }

            if (SecondLocationMeasureMode == null)
            {
                SecondLocationMeasureMode = location;

                var distance = Location.CalculateDistance(
                    FirstLocationMeasureMode,
                    SecondLocationMeasureMode,
                    DistanceUnits.Kilometers
                    );

                DistanceMeasuredKm = Math.Round(distance, 1);

                return;
            }

            //Third click restarts over 
            FirstLocationMeasureMode = location;
            SecondLocationMeasureMode = null;
        }      

        private async Task HandleDefaultMapClicked(Location location)
        {
            var elevationOfPoint = await _openTopoService.GetElevationAsync(location.Latitude, location.Longitude);
            await ShowLocationInformationAlert($"Clicked point in the map:", $"Showing elevation of clicked point:", location.Latitude, location.Longitude);
        }

        private async Task ShowLocationInformationAlert(string label, string address, double latitude, double longitude)
        {
            var elevationOfPoint = await _openTopoService.GetElevationAsync(latitude, longitude);

            var placementInfo = await _geocodingService.GetGeocodingPlacemark(latitude, longitude);

            await _dialogService.ShowAlertAsync(
                    label,
                    address + $"\n\nElevation: {elevationOfPoint} m\n\nGeocoding (Placement) info:\n {placementInfo ?? ""}",
                    "OK"
                ); //on click , alert the pin data also via this marker clicked callback 
        }

        private void InitCabinPins()
        {
            foreach (var cabin in TrondheimCabins.GetSampleData())
            {
                CabinPins.Add(
                    new MapPinModel
                    {
                        Label = cabin.Name,
                        Address = cabin.Description,
                        Latitude = cabin.Latitude,
                        Longitude = cabin.Longitude
                    });
            }
        }

    }
}

MauiProgram Adjustments for custom markers on Android platform with Maui

This section shows the startup wiring that makes the demo work on Android: it registers MAUI Maps, reads the maps keys from user secrets, and appends the custom marker icon mapping used by the measurement pins.

using CommunityToolkit.Maui;
using MauiMapAppDemo.Behaviors;
using MauiMapAppDemo.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Maui.Maps.Handlers;

namespace MauiMapAppDemo
{
    public static class MauiProgram
    {
        public static MauiApp CreateMauiApp()
        {
            var builder = MauiApp.CreateBuilder();
            builder
                .UseMauiApp<App>()
                .UseMauiMaps()
                .UseMauiCommunityToolkit()
                .ConfigureFonts(fonts =>
                {
                    fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                    fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
                });

#if ANDROID
            MapPinHandler.Mapper.AppendToMapping("MeasurementPinIcons", (handler, mapPin) =>
            {
                if (mapPin is not MeasurementPin measurementPin)
                {
                    return;
                }

                var resourceId = GetDrawableResourceId(measurementPin.IconResourceName);
                if (resourceId == 0)
                {
                    return;
                }

                handler.PlatformView.SetIcon(Android.Gms.Maps.Model.BitmapDescriptorFactory.FromResource(resourceId));
            });
#endif

#if DEBUG
            builder.Configuration.AddUserSecrets<App>();
#endif

            string azureMapsKey = builder.Configuration["AzureMapsKey"] ?? string.Empty;
            builder.ConfigureEssentials(essentials => essentials.UseMapServiceToken(azureMapsKey));

#if DEBUG
            builder.Logging.AddDebug();
#endif

            builder.Services.AddSingleton<OpenTopoService>();
            builder.Services.AddSingleton<GeocodingService>();
            builder.Services.AddSingleton<DialogService>();

            return builder.Build();
        }

#if ANDROID
        private static int GetDrawableResourceId(string resourceName)
        {
            if (string.IsNullOrWhiteSpace(resourceName))
            {
                return 0;
            }

            var field = typeof(Resource.Drawable).GetField(
                resourceName,
                System.Reflection.BindingFlags.Public |
                System.Reflection.BindingFlags.Static |
                System.Reflection.BindingFlags.IgnoreCase);

            if (field?.GetValue(null) is int resourceId)
            {
                return resourceId;
            }

            return 0;
        }
#endif
    }
}

Screenshots of the demo running with Android emulator from VS 2026 📸

Here are images of the demo running with the Android emulator. Pixel 7 with API v36 is being used here.

Sunday, 3 May 2026

Inspecting String Length Constraints in EF Core

Inspecting String Length Constraints in EF Core

Entity Framework Core exposes a rich representation of your database schema through its model metadata. This metadata reflects not only attributes on your entities, but also Fluent API configuration, conventions, and provider‑specific decisions.

In this post, we’ll look at a practical way to extract string length constraints from the EF Core model using:

  • C#
  • EF Core 8
  • LINQPad 8

The goal is to surface, programmatically:

  • 🧩 Entity name
  • 🧱 Property name
  • 🏷 Database column name
  • 📏 Configured maximum string length (if any)

All of this is done without reflection and without querying database system tables.


Why Use EF Core Metadata?

It’s tempting to reach for reflection and scan attributes like [MaxLength] or [StringLength]. However, reflection only tells you what was declared — not what EF Core ultimately built.

EF Core metadata reflects the resolved model, which may include:

  • ✅ Fluent API overrides
  • ✅ Convention‑based lengths
  • ✅ Provider defaults
  • ✅ Shadow properties
  • ✅ Mappings that never existed as CLR attributes

If EF Core generated it, the metadata knows about it.


Working in LINQPad 8

When you select an EF Core connection in LINQPad, LINQPad instantiates the DbContext for you. Within the query, that context instance is available via this.

This makes LINQPad an excellent environment for exploratory tooling and schema inspection.

Entry point


void Main()
{    
    var dbContext = (DbContext)this;
    
    Console.WriteLine("----------------- GET ALL THE ENTITY TYPE FIELDS/COLUMNS WITH A STRING LENGTH CONSTRAINT------------");
    
    var stringlengthConstrainedField = dbContext.GetAllStringPropertyLengths();

    foreach (var constrainedField in stringlengthConstrainedField)
    {
        Console.WriteLine($"{constrainedField.EntityName} {constrainedField.PropertyName} {constrainedField.ColumnName} {constrainedField.MaxLength}");
    }
    
    
    Console.WriteLine("-------------- GET SPECIFIC ENTITY TYPE's FIELDS/COLUMS WITH A STRING LENGTH CONSTRAINT---------------");
    
    var constrainedFieldsInSpecificTable = dbContext.GetStringPropertyLengths<Users>();

    foreach (var constrainedField in constrainedFieldsInSpecificTable)
    {
        Console.WriteLine($"{constrainedField.EntityName} {constrainedField.PropertyName} {constrainedField.ColumnName} {constrainedField.MaxLength}");
    }
}

🔎 LINQPad’s immediacy makes this kind of inspection extremely efficient.


DbContext Extension Methods

To keep the querying logic reusable and unobtrusive, the functionality is implemented as DbContext extension methods.

Enumerating all constrained string properties


public static class DbContextExtensions
{
    public static IEnumerable<(string EntityName, string PropertyName, string ColumnName, int? MaxLength)>
        GetAllStringPropertyLengths(this DbContext context, Type? specificEntityType = null)
    {
        var model = context.Model;
        
        IEntityType? specificEntity = null;
        
        if (specificEntityType != null)
        {
            specificEntity = model.FindEntityType(specificEntityType);
        }
        
        if (specificEntityType != null && specificEntity == null)
        {
            yield break;
        }

        var entityTypes = specificEntity == null
            ? model.GetEntityTypes()
            : new[] { specificEntity };
        
        
        foreach (var entityType in entityTypes)
        {
            var clrType = entityType.ClrType;

            foreach (var property in entityType.GetProperties())
            {
                if (property.ClrType != typeof(string))
                    continue;

                var maxLength = property.GetMaxLength();
                if (maxLength == null)
                    continue;

                var propertyName = property.Name;
                var columnName = property.GetColumnName();

                yield return (clrType.Name, propertyName, columnName, maxLength);
            }
        }
    }

🧠 This method reports what EF Core actually enforces, not merely what was declared.


Strongly‑typed convenience overload


    public static IEnumerable<(string EntityName, string PropertyName, string ColumnName, int? MaxLength)>
        GetStringPropertyLengths<TModel>(this DbContext context)
    {
        var stringPropertyLenghtsForType =
            GetAllStringPropertyLengths(context, typeof(TModel));

        return stringPropertyLenghtsForType;
    }
}

🎯 This keeps call‑sites expressive without duplicating logic.


Where This Is Useful

  • ✅ Generating client‑side validation rules
  • ✅ Auditing schema constraints in large models
  • ✅ Verifying legacy databases after reverse engineering
  • ✅ Debugging unexpected truncation or validation errors
  • ✅ Building internal tooling around EF Core models

Because it relies on EF Core metadata, it remains accurate across migrations and configuration changes.


Closing Thoughts

EF Core already builds a comprehensive semantic model of your database schema. Exposing and inspecting that model directly is often simpler—and more reliable— than round‑tripping through reflection or database metadata tables.

LINQPad provides a particularly effective environment for this kind of work: quick, focused, and transparent.

📌 If you’re already using EF Core, you likely don’t need more tooling — you just need to look at the right layer.

Wednesday, 8 April 2026

C# 15 : Union types and the ApiResult Monad in .NET 11

Functional Programming in C# 15: Union Types and the ApiResult Monad

C# has been steadily absorbing ideas from functional programming — pattern matching, records, immutability. With C# 15, we get the feature that ties it all together: union types (discriminated unions). This post walks through why they matter, how they work, and how they enable a clean ApiResult<T> result monad that eliminates try/catch boilerplate and makes error handling composable.

All source code is available on GitHub: UnionTypesDemo1 and ApiResultMonad.


What Does Functional Programming Give Us?

Three things that make code dramatically easier to reason about:

  1. Totality — every function handles every possible input. No hidden exceptions, no nulls sneaking through.
  2. Composability — small pieces snap together into pipelines. You build complex behaviour by chaining simple transformations.
  3. Exhaustiveness — the compiler checks that you handled all cases. Forget one? It tells you at build time, not at 3 AM in production.

Union types are the mechanism that delivers all three in C#. Let’s start with a minimal example.


Union Types — The IntOrBool Example

A union type is a type that holds exactly one of several named cases at a time. No inheritance hierarchies, no object boxing, no OneOf<> libraries — just a closed set of possibilities known to the compiler.

Declaring the Union

public union IntOrBool(int i, bool b)
{
    public readonly bool AsBool() => this switch
    {
        int i  => i != 0,
        bool b => b,
        null   => throw new UnreachableException()
    };

    public readonly int AsInt() => this switch
    {
        int i  => i,
        bool b => b ? 1 : 0,
        null   => throw new UnreachableException()
    };

    public override string ToString() => this switch
    {
        int i  => $"Integer: {i}",
        bool b => $"bool: {b}",
        null   => throw new UnreachableException()
    };
}

One line — public union IntOrBool(int i, bool b); — declares a type that is either an int or a bool. Each member is a case. The compiler enforces exhaustiveness on every switch expression: drop a case and you get a warning (or an error).

Using It

IntOrBool intOrBool = 42;           // holds an int case
Console.WriteLine(intOrBool);               // "Integer: 42"
Console.WriteLine(intOrBool.AsBool());      // False  (0 == false, non-zero check)
Console.WriteLine(intOrBool.AsInt());       // 42

intOrBool = true;                   // reassigned — now holds a bool case
Console.WriteLine(intOrBool);               // "bool: True"
Console.WriteLine(intOrBool.AsBool());      // True
Console.WriteLine(intOrBool.AsInt());       // 1

No cast, no wrapper allocation. The implicit conversion handles it. A single variable can be reassigned across cases — the declared type stays IntOrBool; only the runtime case changes. This is what makes union types ergonomic compared to class hierarchies.

Pattern Matching

string Describe(IntOrBool value) => value switch
{
    int i  => $"It's an integer: {i}",
    bool b => $"It's a boolean: {b}",
    null   => throw new UnreachableException()
};

Exhaustiveness is checked statically. If you add a third case to the union later, every switch site that doesn’t handle it will fail to compile. That’s the kind of safety net you get from functional languages like F# and Rust — now native in C#.


What Is a Monad?

A monad is a design pattern from functional programming. Think of it as a smart wrapper around a value that lets you chain operations without checking for errors at every step. The wrapper carries the result or the failure through the pipeline — you only inspect the outcome at the end.

A monad needs three things:

  1. A wrapper type — something that contains a value (or an error). In our case: ApiResult<T>.
  2. A way to put a value in — often called return or unit. Here: ApiResult.Ok(value).
  3. A way to chain operationsBind (also known as flatMap). Given a wrapped value and a function that returns a new wrapped value, produce the next step in the pipeline.

Classic examples: Option<T> (value may be absent), Result<T, E> (success or error). ApiResult<T> is a result monad specifically tailored for HTTP calls.


The ApiResult<T> Union Type

Using C# 15 union types, we define a type that can be exactly one of three cases:

public record Success<T>(T Data);
public record HttpError(int StatusCode, string Message);
public record TransportError(Exception Exception);

public readonly union ApiResult<T>(
    Success<T> success,
    HttpError httpError,
    TransportError transportLevelError
);

CaseRepresents
Success<T>HTTP 2xx with a valid deserialized body
HttpErrorHTTP 4xx/5xx response
TransportErrorSocket, network, timeout, or other I/O exceptions

Every ApiResult<T> is exactly one of these three — no nulls, no exceptions leaking out, no forgetting to check response.IsSuccessStatusCode. The compiler won’t let you skip a case.


Map — Transform the Happy Path

Map applies a function to the inner value if it is a success. Errors pass through unchanged — you stay on the “happy rail” and errors propagate automatically. This is the functional alternative to writing if (result.IsSuccess) checks at every step.

public ApiResult<TResult> Map<TResult>(Func<T, TResult> f) => Value switch
{
    Success<T> s      => new Success<TResult>(f(s.Data)),
    HttpError h        => new HttpError(h.StatusCode, h.Message),
    TransportError t   => new TransportError(t.Exception),
    _                  => new HttpError(500, "Unhandled error")
};

Usage is clean:

ApiResult<string> title = ApiResult.Ok(todo)
    .Map(t => t.Title.ToUpperInvariant());

If todo was actually an HttpError or TransportError, the lambda is never invoked — the error flows through untouched. No if, no try/catch.


Bind — Chain Operations That Can Fail

Bind (a.k.a. flatMap) is for sequencing operations where the next step can itself fail. It unwraps the value and hands it to a function that returns a new ApiResult<TResult>, preventing the nested ApiResult<ApiResult<T>> problem that Map would produce.

public ApiResult<TResult> Bind<TResult>(Func<T, ApiResult<TResult>> f) => Value switch
{
    Success<T> s      => f(s.Data),
    HttpError h        => new HttpError(h.StatusCode, h.Message),
    TransportError t   => new TransportError(t.Exception),
    _                  => new HttpError(500, "Unhandled error")
};

Usage:

ApiResult<string> result = ApiResult.Ok(42)
    .Bind(id => id > 0
        ? ApiResult.Ok(id.ToString())
        : ApiResult.HttpFail<string>(HttpStatusCode.BadRequest, "Invalid id"));

Map vs Bind — At a Glance

OperationLambda signatureUse when
MapT → TResultTransforming data (can’t introduce new failures)
BindT → ApiResult<TResult>Next step can also fail

The GetJsonAsync Extension

The entry point into the monad is an extension method on HttpClient that wraps the entire HTTP call — success, HTTP errors, and transport exceptions — into an ApiResult<T>:

public static async Task<ApiResult<T>> GetJsonAsync<T>(
    this HttpClient httpClient, string url)
{
    try
    {
        using var response = await httpClient.GetAsync(url).ConfigureAwait(false);

        if (!response.IsSuccessStatusCode)
        {
            return new HttpError(
                (int)response.StatusCode,
                await response.Content.ReadAsStringAsync().ConfigureAwait(false));
        }

        await using var stream = await
            response.Content.ReadAsStreamAsync().ConfigureAwait(false);

        var val = await JsonSerializer.DeserializeAsync<T>(stream,
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
            .ConfigureAwait(false);

        return val is not null
            ? new Success<T>(val)
            : ApiResult.HttpFail<T>(
                HttpStatusCode.UnprocessableEntity,
                $"No content or wrong content for type: {typeof(T).Name}");
    }
    catch (Exception ex)
    {
        return new TransportError(ex);
    }
}

No try/catch at the call site. Errors flow through the monad.


Chaining — and Why ContinueWith Is Not the Right Tool

The original example chains MapAsync and Bind like this:

var summary = await result
    .MapAsync(async todo => todo with { Title = todo.Title.ToUpperInvariant() })
    .ContinueWith(t => t.Result.Bind(todo =>
        todo.Completed
            ? ApiResult.Ok($"Done: {todo.Title}")
            : ApiResult.HttpFail<string>(HttpStatusCode.UnprocessableEntity, "Not completed")));

This works, but ContinueWith is a Task-level continuation — it belongs to TPL plumbing, not to monad composition. It has well-known pitfalls:

  • It does not capture SynchronizationContext by default (unlike await).
  • It swallows exceptions into AggregateException unless you explicitly unwrap.
  • It forces you to reach into t.Result, mixing two abstraction levels.

The cleaner fix is to add extension methods that operate on Task<ApiResult<T>> directly, so async and sync monadic operations compose seamlessly:

public static class ApiResultTaskExtensions
{
    /// <summary>
    /// Chains a synchronous Map on an async ApiResult pipeline.
    /// </summary>
    public static async Task<ApiResult<TResult>> MapAsync<T, TResult>(
        this Task<ApiResult<T>> task, Func<T, TResult> f)
    {
        var result = await task.ConfigureAwait(false);
        return result.Map(f);
    }

    /// <summary>
    /// Chains a synchronous Bind on an async ApiResult pipeline.
    /// </summary>
    public static async Task<ApiResult<TResult>> BindAsync<T, TResult>(
        this Task<ApiResult<T>> task, Func<T, ApiResult<TResult>> f)
    {
        var result = await task.ConfigureAwait(false);
        return result.Bind(f);
    }

    /// <summary>
    /// Chains an async Bind on an async ApiResult pipeline.
    /// </summary>
    public static async Task<ApiResult<TResult>> BindAsync<T, TResult>(
        this Task<ApiResult<T>> task, Func<T, Task<ApiResult<TResult>>> f)
    {
        var result = await task.ConfigureAwait(false);
        return await result.BindAsync(f).ConfigureAwait(false);
    }
}


Fluent Multi-Step Chaining

With those extensions in place, you can chain Map and Bind as many times as you want — all in a single fluent pipeline, no ContinueWith in sight:

var summary = await httpClient
    .GetJsonAsync<Todo>("https://jsonplaceholder.typicode.com/todos/4")
    // Step 1 (Map): uppercase the title
    .MapAsync(todo => todo with { Title = todo.Title.ToUpperInvariant() })
    // Step 2 (Map): prefix with ID
    .MapAsync(todo => todo with { Title = $"[{todo.Id}] {todo.Title}" })
    // Step 3 (Bind): fail if not completed, otherwise produce summary string
    .BindAsync(todo => todo.Completed
        ? ApiResult.Ok($"Completed: {todo.Title}")
        : ApiResult.HttpFail<string>(HttpStatusCode.BadRequest, "Not done yet"))
    // Step 4 (Map): final formatting
    .MapAsync(msg => $">> {msg} <<");

Console.WriteLine(summary.Value switch
{
    Success<string> s  => s.Data,
    HttpError h         => $"Error {h.StatusCode}: {h.Message}",
    TransportError t    => $"Transport: {t.Exception.Message}",
    _                   => "?"
});

If any step fails — say the HTTP call returns 404, or the Bind rejects a non-completed todo — every subsequent Map and Bind is skipped automatically. The error propagates through the pipeline untouched until you pattern-match at the end. This is the “railway-oriented programming” pattern: one rail for success, one for errors, and the switch track is Bind.

What About Multiple Async Steps?

The BindAsync overload that takes Func<T, Task<ApiResult<TResult>>> lets you chain operations that are themselves async and can fail — e.g., calling a second API based on the result of the first:

var enriched = await httpClient
    .GetJsonAsync<Todo>(url)
    .MapAsync(todo => todo with { Title = todo.Title.ToUpperInvariant() })
    .BindAsync(async todo =>
    {
        // Second HTTP call — also returns ApiResult<T>
        var userResult = await httpClient
            .GetJsonAsync<User>($"https://jsonplaceholder.typicode.com/users/{todo.UserId}");

        return userResult.Map(user => $"{user.Name}: {todo.Title}");
    });

Each step composes cleanly. The pipeline reads top-to-bottom exactly like the business logic it represents.


Consuming the Result

At the edge of your pipeline, use a switch expression to exhaustively handle all three outcome cases:

var output = result.Value switch
{
    Success<Todo> s    => $"OK: {s.Data.Title}",
    HttpError h         => $"HTTP {h.StatusCode}: {h.Message}",
    TransportError t    => $"Transport error: {t.Exception.Message}",
    _                   => "Unknown"
};

The compiler enforces exhaustiveness. You cannot forget a case.


Requirements & Setup

RequirementValue
IDEVS Code Insiders
ExtensionsC# Dev Kit + C# — both set to Pre-Release channel
Target frameworknet11.0 (Update 2)
Language version<LangVersion>preview</LangVersion> in .csproj

Union types are a C# 15 preview feature. The preview language version and preview extension channels are required for compiler support.


Key Takeaways

  1. Union types close the gap between C# and languages like F#/Rust. A single union keyword gives you a closed, exhaustive, pattern-matchable type.
  2. Monads make error handling composable. ApiResult<T> carries success or failure through a pipeline — no try/catch spaghetti, no null checks.
  3. Map and Bind are the building blocks. Map transforms data. Bind sequences operations that can fail. Together they give you railway-oriented programming in idiomatic C#.
  4. Avoid ContinueWith for monad chaining. Add Task<ApiResult<T>> extension methods instead. It keeps async and monadic composition at the same abstraction level and eliminates TPL pitfalls.
  5. Fluent pipelines read like business logic. Chain as many Map and Bind steps as you need. Errors propagate automatically — you only handle them once, at the end.

Source code: UnionTypesDemo1 (IntOrBool)ApiResultMonad

Tuesday, 31 March 2026

Generic EqualityComparer for classes in C#

GenericEqualityComparer

Creating support for Equality comparison of classes in C# can become repititive. In this article, we will look at a Generic equality comparer that can be used for classes to do equality comparison. Please note that we are meaning here value comparison. Structs and records support via built-in functionality such a value equality comparison. For classes, it depends on what you mean by equality comparison. Usually it means the public properties, but additional state such as private properties and fields can also be considered.

Github repo with this source code

https://github.com/toreaurstadboss/GenericEqualityComparer

A reflection-based IEqualityComparer<T> that compares two objects by their member values instead of by reference. Useful for plain C# classes that don't override Equals and GetHashCode themselves.

1 — The problem it solves

In C#, class instances are compared by reference by default. Two objects with identical data are not equal unless they are the same object in memory.

var car1 = new Car { Make = "Toyota", Model = "Camry", Year = 2020 };
var car2 = new Car { Make = "Toyota", Model = "Camry", Year = 2020 };

Console.WriteLine(car1 == car2);        // False — different object references
Console.WriteLine(car1.Equals(car2));   // False — same reason

GenericEqualityComparer<T> solves this without touching the class itself. It uses reflection to compare each property (and optionally each field) value by value. But it also uses member expressions compiled into delegates to provide fast member value lookups. This makes the generic equality comparer used here possible to use inside collections with many items.

Lets first look at the GenericEqualityComparer source code. It is a generic class.

GenericEqualityComparer.cs


using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using System.Reflection;

namespace GenericEqualityComparer.Lib
{

    /// <summary>
    /// A reflection-based <see cref="IEqualityComparer{T}"/> that compares instances of
    /// <typeparamref name="T"/> by their members rather than by reference.
    /// This is intended for use with classes that do not implement their own value-based equality semantics, and is not recommended for performance-sensitive scenarios.
    /// Types such as structs and records already have built-in value equality semantics and should not require this comparer.
    /// </summary>
    /// <typeparam name="T">The type to compare. Must be a reference type.</typeparam>
    /// <remarks>
    /// By default only public instance properties are compared. Pass the constructor flags to
    /// also include private properties and/or fields (public or private).
    /// </remarks>
    public class GenericEqualityComparer<T> : IEqualityComparer<T> where T : class
    {

        private List<Func<T, object>> _propertyGetters = new List<Func<T, object>>(); // Cache of compiled delegates for accessing the configured properties of T, used to avoid the performance overhead of reflection during comparisons.

        private List<Func<T, object>> _fieldGetters = new List<Func<T, object>>(); // Cache of compiled delegates for accessing the configured fields of T, used to avoid the performance overhead of reflection during comparisons.

        /// <summary>
        /// Initialises the comparer and builds the member accessor cache.
        /// </summary>
        /// <param name="includeFields">When <see langword="true"/>, public instance fields are included in the comparison.</param>
        /// <param name="includePrivateProperties">When <see langword="true"/>, private instance properties are included in the comparison.</param>
        /// <param name="includePrivateFields">When <see langword="true"/>, private instance fields are included in the comparison. Also enables public field comparison.</param>
        public GenericEqualityComparer(bool includeFields = false, bool includePrivateProperties = false, bool includePrivateFields = false)
        {
            CreatePropertyGetters(includePrivateProperties);
            if (includeFields || includePrivateFields)
            {
                CreateFieldGetters(includePrivateFields);
            }
        }

        private void CreatePropertyGetters(bool includePrivateProperties)
        {
            var bindingFlags = BindingFlags.Instance | BindingFlags.Public;
            if (includePrivateProperties)
            {
                bindingFlags |= BindingFlags.NonPublic;
            }

            var props = typeof(T).GetProperties(bindingFlags).Where(m => m.GetMethod != null).ToList();

            foreach (var prop in props)
            {

                //Builds the Expression<Func<T, object>> for the property getter and compiles it into a Func<T, object> delegate, which is cached for later use.
                ParameterExpression parameter = Expression.Parameter(typeof(T), "p");
                MemberExpression propertyExpression = Expression.Property(parameter, prop.Name);
                Expression boxedPropertyExpression = Expression.Convert(propertyExpression, typeof(object));
                Expression<Func<T, object>> propertyGetter = Expression.Lambda<Func<T, object>>(boxedPropertyExpression, parameter);
                _propertyGetters.Add(propertyGetter.Compile());
            }
        }

        private void CreateFieldGetters(bool includePrivateFields)
        {
            var bindingFlags = BindingFlags.Instance | BindingFlags.Public;
            if (includePrivateFields)
            {
                bindingFlags |= BindingFlags.NonPublic;
            }

            var fields = typeof(T).GetFields(bindingFlags).ToList();

            foreach (var field in fields)
            {
                // Builds the Expression<Func<T, object>> for the field getter and compiles it into a Func<T, object> delegate, which is cached for later use.
                ParameterExpression parameter = Expression.Parameter(typeof(T), "f");
                MemberExpression fieldExpression = Expression.Field(parameter, field.Name);
                Expression boxedPropertyExpression = Expression.Convert(fieldExpression, typeof(object));
                Expression<Func<T, object>> fieldGetter = Expression.Lambda<Func<T, object>>(boxedPropertyExpression, parameter);
                _fieldGetters.Add(fieldGetter.Compile());
            }

        }

        /// <summary>
        /// Determines whether <paramref name="x"/> and <paramref name="y"/> are equal by comparing
        /// each configured member in turn.
        /// </summary>
        /// <param name="x">The first object to compare.</param>
        /// <param name="y">The second object to compare.</param>
        /// <returns>
        /// <see langword="true"/> when all configured members are equal;
        /// <see langword="false"/> when any member differs, or either argument is <see langword="null"/>.
        /// </returns>
        public bool Equals(T? x, T? y)
        {
            if (x == null || y == null)
            {
                return false;
            }
            if (ReferenceEquals(x, y))
            {
                return true;
            }
            if (x.GetType() != y.GetType())
            {
                return false;
            }

            foreach (var propAccessor in _propertyGetters)
            {
                var xv = propAccessor(x);
                var yv = propAccessor(y);
                if (!xv.Equals(yv))
                {
                    return false;
                }
            }

            foreach (var fieldAccessor in _fieldGetters)
            {
                var xv = fieldAccessor(x);
                var yv = fieldAccessor(y);
                if (!xv.Equals(yv))
                {
                    return false;
                }

            }


            return true;
        }

        /// <summary>
        /// Returns an <see cref="EqualityWrapper{T}"/> for <paramref name="value"/> so that
        /// <c>==</c> and <c>!=</c> use this comparer's configured equality semantics.
        /// </summary>
        /// <param name="value">The value to wrap.</param>
        /// <returns>An <see cref="EqualityWrapper{T}"/> bound to this comparer instance.</returns>
        public EqualityWrapper<T> For(T value) => new EqualityWrapper<T>(value, this);

        /// <summary>
        /// Returns a hash code for <paramref name="obj"/> derived from the same configured members
        /// used by <see cref="Equals(T, T)"/>.
        /// </summary>
        /// <param name="obj">The object to hash.</param>
        /// <returns>A hash code consistent with the configured equality semantics.</returns>
        public int GetHashCode([DisallowNull] T obj)
        {
            int hash = 0;

            var propertyValues = _propertyGetters.Select(p => p(obj)).ToList();

            for (int i = 0; i < propertyValues.Count; i += 8)
            {
                hash = HashCode.Combine(hash,
                    propertyValues.ElementAtOrDefault(i),
                    propertyValues.ElementAtOrDefault(i + 1),
                    propertyValues.ElementAtOrDefault(i + 2),
                    propertyValues.ElementAtOrDefault(i + 3),
                    propertyValues.ElementAtOrDefault(i + 4),
                    propertyValues.ElementAtOrDefault(i + 5),
                    propertyValues.ElementAtOrDefault(i + 6));
            }

            if (_fieldGetters.Any())
            {
                var fieldValues = _fieldGetters.Select(f => f(obj)).ToList();
                for (int i = 0; i < fieldValues.Count; i += 8)
                {
                    hash = HashCode.Combine(hash,
                        fieldValues.ElementAtOrDefault(i),
                        fieldValues.ElementAtOrDefault(i + 1),
                        fieldValues.ElementAtOrDefault(i + 2),
                        fieldValues.ElementAtOrDefault(i + 3),
                        fieldValues.ElementAtOrDefault(i + 4),
                        fieldValues.ElementAtOrDefault(i + 5),
                        fieldValues.ElementAtOrDefault(i + 6));
                }
            }

            return hash;
        }

    }

}


The method For accepts a object instance of type T and returns a EqualityWrapper struct that allows the usage of operator == and !=

EqualityWrapper.cs



namespace GenericEqualityComparer.Lib;

/// <summary>
/// Pairs a value of type <typeparamref name="T"/> with a <see cref="GenericEqualityComparer{T}"/>
/// so that <c>==</c> and <c>!=</c> use the comparer's configured equality semantics instead of
/// reference equality.
/// </summary>
/// <typeparam name="T">The type of the wrapped value. Must be a reference type.</typeparam>
/// <remarks>
/// Obtain an instance via <see cref="GenericEqualityComparer{T}.For"/>:
/// <code>comparer.For(car1) == comparer.For(car2)</code>
/// </remarks>
public readonly struct EqualityWrapper<T> where T : class
{
    private readonly T _value;
    private readonly GenericEqualityComparer<T> _comparer;

    internal EqualityWrapper(T value, GenericEqualityComparer<T> comparer)
    {
        _value = value;
        _comparer = comparer;
    }

    /// <summary>
    /// Returns <see langword="true"/> when <paramref name="left"/> and <paramref name="right"/>
    /// are considered equal by their shared comparer.
    /// </summary>
    public static bool operator ==(EqualityWrapper<T> left, EqualityWrapper<T> right)
        => left._comparer.Equals(left._value, right._value);

    /// <summary>
    /// Returns <see langword="true"/> when <paramref name="left"/> and <paramref name="right"/>
    /// are not considered equal by their shared comparer.
    /// </summary>
    public static bool operator !=(EqualityWrapper<T> left, EqualityWrapper<T> right)
        => !(left == right);

    /// <inheritdoc/>
    public override bool Equals(object? obj)
        => obj is EqualityWrapper<T> other && this == other;

    /// <inheritdoc/>
    public override int GetHashCode()
        => _comparer.GetHashCode(_value);
}



2 — Quick start

2.1 Compare public properties

using GenericEqualityComparer.Lib;

var comparer = new GenericEqualityComparer<Car>();

var car1 = new Car { Make = "Toyota", Model = "Camry", Year = 2020 };
var car2 = new Car { Make = "Toyota", Model = "Camry", Year = 2020 };
var car3 = new Car { Make = "Toyota", Model = "Corolla", Year = 2020 };

Console.WriteLine(comparer.Equals(car1, car2));  // True  — all properties match
Console.WriteLine(comparer.Equals(car1, car3));  // False — Model differs

2.2 Use it with LINQ or collections

Because GenericEqualityComparer<T> implements IEqualityComparer<T> you can pass it directly to LINQ methods and collection APIs that accept one.

var cars = new List<Car>
{
    new Car { Make = "Toyota", Model = "Camry",   Year = 2020 },
    new Car { Make = "Toyota", Model = "Camry",   Year = 2020 }, // duplicate
    new Car { Make = "Toyota", Model = "Corolla", Year = 2021 },
};

var comparer = new GenericEqualityComparer<Car>();

// Distinct by value
var unique = cars.Distinct(comparer).ToList();  // 2 items

// GroupBy by value
var grouped = cars.GroupBy(c => c, comparer);

3 — Constructor options

The constructor accepts three optional boolean flags. All default to false.

Parameter Type What it includes
includeFields bool Public instance fields
includePrivateProperties bool Private instance properties
includePrivateFields bool Private instance fields (also enables public fields)

3.1 Include private fields

Imagine a Car class that stores a secret assembly number in a private field:

public class Car
{
    public string Make { get; set; } = string.Empty;
    public string Model { get; set; } = string.Empty;
    public int Year { get; set; }

    // private — not visible to external code
    private string _secretAssemblyNumber = string.Empty;
    public void SetSecretAssemblyNumber(string number) => _secretAssemblyNumber = number;
}
var ford1 = new Car { Make = "Ford", Model = "Focus", Year = 2022 };
var ford2 = new Car { Make = "Ford", Model = "Focus", Year = 2022 };
ford1.SetSecretAssemblyNumber("ASM-001");
ford2.SetSecretAssemblyNumber("ASM-999");  // intentionally different

// Default comparer — only sees public properties, ignores the private field
var defaultComparer = new GenericEqualityComparer<Car>();
Console.WriteLine(defaultComparer.Equals(ford1, ford2));  // True (field ignored)

// Include private fields — now the hidden difference is detected
var deepComparer = new GenericEqualityComparer<Car>(includePrivateFields: true);
Console.WriteLine(deepComparer.Equals(ford1, ford2));     // False

3.2 Include private properties

The same idea applies when a class uses a private property as an internal identifier:

public class Bicycle
{
    public string Brand { get; set; } = string.Empty;
    public string Model { get; set; } = string.Empty;

    private string FrameSerialNumber { get; set; } = string.Empty;
    public void SetFrameSerialNumber(string sn) => FrameSerialNumber = sn;
}
var bike1 = new Bicycle { Brand = "Trek", Model = "FX3" };
var bike2 = new Bicycle { Brand = "Trek", Model = "FX3" };
bike1.SetFrameSerialNumber("SN-001");
bike2.SetFrameSerialNumber("SN-999");

var defaultComparer = new GenericEqualityComparer<Bicycle>();
Console.WriteLine(defaultComparer.Equals(bike1, bike2));  // True

var deepComparer = new GenericEqualityComparer<Bicycle>(includePrivateProperties: true);
Console.WriteLine(deepComparer.Equals(bike1, bike2));     // False

4 — EqualityWrapper<T> and the == / != operators

C# doesn't allow overloading == and != on a generic type parameter T in an external comparer class. As a workaround, GenericEqualityComparer<T> exposes a For(value) method that returns an EqualityWrapper<T>. The wrapper carries both the value and the comparer, so its == and != operators delegate to the comparer instead of defaulting to reference equality.

4.1 Basic operator usage

var comparer = new GenericEqualityComparer<Car>();

var car1 = new Car { Make = "Toyota", Model = "Camry", Year = 2020 };
var car2 = new Car { Make = "Toyota", Model = "Camry", Year = 2020 };
var car3 = new Car { Make = "Toyota", Model = "Corolla", Year = 2020 };

bool same      = comparer.For(car1) == comparer.For(car2);  // True
bool different = comparer.For(car1) != comparer.For(car3);  // True

4.2 With private member detection

var deepComparer = new GenericEqualityComparer<Car>(includePrivateFields: true);

var ford1 = new Car { Make = "Ford", Model = "Focus", Year = 2022 };
var ford2 = new Car { Make = "Ford", Model = "Focus", Year = 2022 };
ford1.SetSecretAssemblyNumber("ASM-001");
ford2.SetSecretAssemblyNumber("ASM-999");

if (deepComparer.For(ford1) != deepComparer.For(ford2))
{
    Console.WriteLine("Cars differ (private field detected)");
}

4.3 Consistent hashing

EqualityWrapper<T> also overrides GetHashCode() so it stays consistent with ==. This means wrapped values can be used safely as dictionary keys or in hash sets.

var comparer = new GenericEqualityComparer<Car>();
var car1     = new Car { Make = "Toyota", Model = "Camry", Year = 2020 };
var car2     = new Car { Make = "Toyota", Model = "Camry", Year = 2020 };

int hash1 = comparer.For(car1).GetHashCode();
int hash2 = comparer.For(car2).GetHashCode();

Console.WriteLine(hash1 == hash2);  // True — equal objects, equal hashes

5 — When not to use it

Performance: The comparer uses reflection to discover members at construction time (compiled to delegates for speed), but it is still a little slower than a hand-written Equals. Avoid it in tight loops or hot paths.
  • Records — C# records already have value equality built in. Use == directly.
  • Structs — Same as records; value equality is the default.
  • Classes you own — Prefer overriding Equals / GetHashCode or implementing IEquatable<T> for production code (due to performance). Use this comparer for tests, prototyping, or third-party types you can't modify. Or if you just would like a simple way of providing value based equality checks, but in that case you should
    really
    consider a specific implementation.
In case you work with generated code or for got a large number of POCO classes (Data transfer objects) and want to avoid using inheritance or adding value equality of your existing code, this code allows you adding value based equality, this code shown here should have you covered with a generic util class.

6 - Supported Frameworks

Please note that since we use HashCode here, supported target frameworks are netstandard 2.1 and .netcore 2.1 or later. In case you use .NET Framework 4.8 or earlier for example, you can provide a GetHashCode implementation like the following:

GetHashCode that avoids using HashCode.Combine

We can instead use two selected prime numbers and multipliers to calculate a hash of the object's propertis and fields like the following:


public int GetHashCode([DisallowNull] T obj)
{
    int hash = 0;

    var propertyValues = _propertyGetters.Select(p => p(obj)).ToList();

    for (int i = 0; i < propertyValues.Count; i += 8)
    {
        hash = Combine(hash,
            propertyValues.ElementAtOrDefault(i),
            propertyValues.ElementAtOrDefault(i + 1),
            propertyValues.ElementAtOrDefault(i + 2),
            propertyValues.ElementAtOrDefault(i + 3),
            propertyValues.ElementAtOrDefault(i + 4),
            propertyValues.ElementAtOrDefault(i + 5),
            propertyValues.ElementAtOrDefault(i + 6));
    }

    if (_fieldGetters.Any())
    {
        var fieldValues = _fieldGetters.Select(f => f(obj)).ToList();
        for (int i = 0; i < fieldValues.Count; i += 8)
        {
            hash = Combine(hash,
                fieldValues.ElementAtOrDefault(i),
                fieldValues.ElementAtOrDefault(i + 1),
                fieldValues.ElementAtOrDefault(i + 2),
                fieldValues.ElementAtOrDefault(i + 3),
                fieldValues.ElementAtOrDefault(i + 4),
                fieldValues.ElementAtOrDefault(i + 5),
                fieldValues.ElementAtOrDefault(i + 6));
        }
    }

    return hash;
}

private static int Combine(params object[] values)
{
    unchecked
    {
        int hash = 17;
        foreach (var v in values)
        {
            int h = v?.GetHashCode() ?? 0;
            hash = hash * 31 + h;
        }
        return hash;
    }
}


The strange selection of two prime numbers 17 and factor of 31 is to provide diffusion to avoid hash collisions and avoid also trouble with objects with symmetric values (a,b) equaling (b,a) is avoided
using this way of summing the hashes from each property. The HashCode.Combine allows us to avoid this.

7 — Summary

The article has presented a way to do value equality checks for instances of classes in a generic manner supporting an arbitrary number of public (and private) properties, possibly also including fields (and private fields). If you want an easy way of adding value equality checks in classes and performance allows using the expression compiled delegates shown here with a little overhead initially, you should be able to consider the code here for some scenarios. The Github Repo of mine for this source code contains a lot of tests, so the code is tested.
What you wantHow
Compare public properties new GenericEqualityComparer<T>()
Also include public fields new GenericEqualityComparer<T>(includeFields: true)
Also include private properties new GenericEqualityComparer<T>(includePrivateProperties: true)
Also include private fields new GenericEqualityComparer<T>(includePrivateFields: true)
Use == / != operators comparer.For(a) == comparer.For(b)
Use with LINQ list.Distinct(comparer), list.GroupBy(x => x, comparer)