Showing posts with label c#. Show all posts
Showing posts with label c#. Show all posts

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, 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.

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)

Sunday, 15 February 2026

Blazor Property Grid - Updated version

Blazor Property Grid

I wrote a Blazor Property Grid component back in 2021, the updated the version is now available some five years later.

You will find the property grid available on Nuget here:

https://www.nuget.org/packages/BlazorPropertyGridComponents/1.2.4

The property grid allows inspection of an object's properties and also edit them. Supported data types are the fundamental data types, which means integers, date times, booleans, strings and numbers.

The property grid supports nested properties, properties that are compound objects themselves. I have not yet added template supported for custom data types, but fundamentals nested properties inside the complex property are shown. This means you can drill down into an object and both inspect the object and also edit the properties that are the mentioned fundamental data types.

This project delivers a Blazor property‑grid component capable of inspecting and editing both top‑level and deeply nested properties of an object. It works smoothly with nested structures and internal members.

It has been verified using Blazor WebAssembly running on .NET. 10. A sample client is included in the BlazorSampleClient project.

The component implementation is located inside the Razor Class Library BlazorPropertyGridComponents.

Licensing is MIT and the component is provided as‑is. If used in production, you are responsible for validating its suitability. Forks, modifications, and commercial reuse are allowed. The project originated as a personal learning exercise.

The screenshot below (from the sample client) illustrates the property grid on the right side updating the same model that the form on the left is bound to.

The component expects your project to include Bootstrap and Font Awesome. You can inspect exact versions inside libman.json. Additional styling resides in styles.css.

libman.json example

{
  "version": "1.0",
  "defaultProvider": "cdnjs",
  "libraries": [
    {
      "library": "bootstrap@5.3.3",
      "destination": "wwwroot/bootstrap/"
    },
    {
      "library": "font-awesome@6.5.1",
      "destination": "wwwroot/font-awesome/"
    }
  ]
}

Supported Property Types

  • DateTime (full or date‑only)
  • Float
  • Decimal
  • Int
  • Double
  • String
  • Bool
  • Enums (rendered using <select> and <option>)

Using the Component – API

Below is a Razor example showing how to use the component. The PropertySetValueCallback is optional and only required if you want UI changes reflected elsewhere immediately.

<div class="col-md-6"> <!-- Property grid shown in the right column -->
  <h4 class="mb-3">Property Grid Component Demo</h4>

  <PropertyGridComponent
      PropertySetValueCallback="OnPropertyValueSet"
      ObjectTitle="Property grid - Edit form for : 'Customer Details'"
      DataContext="@exampleModel">
  </PropertyGridComponent>
</div>

@code {
  private void OnPropertyValueSet(PropertyChangedInfoNotificationInfoPayload pi)
  {
    if (pi != null)
    {
      JsRunTime.InvokeVoidAsync(
        "updateEditableField",
        pi.FieldName,
        pi.FullPropertyPath,
        pi.Value
      );
    }
  }

  private CustomerModel exampleModel = new CustomerModel
  {
    Address = new AddressInfo
    {
      Zipcode = 7045,
      AddressDetails = new AddressInfoDetails
      {
        Box = "PO Box 123"
      }
    }
  };

  private void HandleValidSubmit()
  {
  }
}

The JavaScript function updateEditableField is located in script.js.

Updating via C# Instead of JS

You can also update the model directly through reflection. This approach ensures proper Blazor re‑rendering. Be sure to call StateHasChanged(). Note that this code is only required in case you use the property grid for editing properties and show the object you edit other places on the same page.

@code {

  private void OnPropertyValueSet(PropertyChangedInfoNotificationInfoPayload pi)
  {
    if (pi == null)
      return;

    SetPropertyByPath(exampleModel, pi.FullPropertyPath, pi.Value, pi.ValueType);
    StateHasChanged();
  }

  private void SetPropertyByPath(object target, string propertyPath, object value, string valueType)
  {
    if (target == null || string.IsNullOrEmpty(propertyPath))
      return;

    var parts = propertyPath.Split('.');
    var current = target;

    // Move to parent object

        // Navigate to the parent object
        for (int i = 0; i < parts.Length - 1; i++)
        {
            var prop = current.GetType().GetProperty(parts[i]);
            if (prop == null) return;
            current = prop.GetValue(current);
            if (current == null) return;
        }

        // Set the final property
        var finalProp = current.GetType().GetProperty(parts[^1]);
        if (finalProp == null) return;

        try
        {
            object convertedValue = value;

            if (finalProp.PropertyType.IsEnum && value != null)
            {
                var valStr = value.ToString();
                if (int.TryParse(valStr, out int intVal))
                    convertedValue = Enum.ToObject(finalProp.PropertyType, intVal);
                else
                    convertedValue = Enum.Parse(finalProp.PropertyType, valStr, ignoreCase: true);
            }
            else if (finalProp.PropertyType == typeof(bool) && value != null)
            {
                convertedValue = Convert.ToBoolean(value);
            }
            else if (value != null && finalProp.PropertyType != typeof(string))
            {
                convertedValue = Convert.ChangeType(value, finalProp.PropertyType);
            }

            finalProp.SetValue(current, convertedValue);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Failed to set {propertyPath}: {ex.Message}");
        }
    }

    private CustomerModel exampleModel = new CustomerModel
    {
        Address = new AddressInfo
        {
            Zipcode = 7045,
            AddressDetails = new AddressInfoDetails
            {
                Box = "PO Box 123"
            }
        }
    };

    private void HandleValidSubmit()
    {

    }

}

I have used Claude Haiku 4.5 LLM to create a nice Architecture Documentation of my component so it is more convenient to see the structure of the Blazor property grid component. This is handy for those developers who wants to work on the component and add features and understood its structure. As mentioned before, the component is licensed with MIT license and you can adjust the component as needed free of use (and responsiblity).

🔷 Blazor Property Grid Component - Architecture Documentation


📊 Property Grid Component Structure

PROPERTY GRID COMPONENT ARCHITECTURE
====================================

ROOT CONTAINER: PropertyGridComponent.razor
├── EditForm (wraps the entire grid)
└── .property-grid-container
    ├── Header Table (.property-grid-header-table)
    │   └── thead (.property-grid-header)
    │       └── tr
    │           ├── th: "Property"
    │           ├── th: "Value"
    │           └── th: Edit Button (pencil icon)
    │
    └── Body Table (.property-grid-body-table)
        └── tbody (.property-grid-body)
            └── foreach KeyValuePair in Props
                ├── IF: Simple Property (IsClass = false)
                │   └── [COMMENTED OUT - NOT DISPLAYED]
                │
                └── IF: Nested Class (IsClass = true)
                    └── tr
                        ├── td (colspan=2)
                        │   ├── Expand/Collapse Button (minus icon)
                        │   └── div (.collapse .show)
                        │       └── PropertyRowComponent [Depth=1]
                        └── td (empty)


PROPERTY ROW COMPONENT: PropertyRowComponent.razor
====================================================

FOR EACH SubProperty in PropertyInfoAtLevel.SubProperties:
├── IF: Simple Type (not a class or System namespace)
│   └── tr (.property-row)
│       ├── td (.property-name-cell)
│       │   └── span (.property-name) = Property Name
│       │
│       └── td (.property-value-cell)
│           ├── IF: DateTime
│           │   └── InputDate (if editable) OR span (if readonly)
│           │
│           ├── IF: bool
│           │   └── InputCheckbox (if editable) OR span (if readonly)
│           │
│           ├── IF: int
│           │   └── InputNumber (if editable) OR span (if readonly)
│           │
│           ├── IF: double
│           │   └── InputText type="number" (if editable) OR span (if readonly)
│           │
│           ├── IF: decimal
│           │   └── InputText type="number" (if editable) OR span (if readonly)
│           │
│           ├── IF: float
│           │   └── InputText type="number" (if editable) OR span (if readonly)
│           │
│           ├── IF: string
│           │   └── InputText type="text" (if editable) OR span (if readonly)
│           │
│           ├── IF: Enum
│           │   └── select (if editable)
│           │       └── option foreach Enum value
│           │       OR span (if readonly)
│           │
│           └── ELSE: Unknown Type
│               └── span = Raw Value
│
└── IF: Nested Class (PropertyValue is HierarchicalPropertyInfo)
    └── tr (.property-row .nested-property-row)
        ├── td (colspan=2, .nested-property-cell)
        │   ├── span (.nested-property-name) = Nested Class Name
        │   ├── Expand/Collapse Button (plus icon)
        │   └── div (.collapse or .collapse.show)
        │       └── PropertyRowComponent [Depth+1] (RECURSIVE)
        └── [Empty td]


DATA STRUCTURE: HierarchicalPropertyInfo
================================================

HierarchicalPropertyInfo
├── PropertyName: string
├── PropertyValue: object
├── PropertyType: Type
├── SubProperties: Dictionary<string, HierarchicalPropertyInfo>
├── FullPropertyPath: string (dot-separated path)
├── IsClass: bool (indicates if this is a class type)
├── IsEditable: bool
├── NewValue: object (for tracking changes)
└── ValueSetCallback: EventCallback

HIERARCHY BUILD PROCESS:
========================

MapPropertiesOfDataContext(root object)
├── Create ROOT HierarchicalPropertyInfo
├── For each Public Property:
│   ├── IF: Simple Type (not class or not System namespace)
│   │   └── Add to SubProperties as leaf node (IsClass=false)
│   │
│   └── IF: Nested Class (class type, not System namespace)
│       └── Recursively call MapPropertiesOfDataContext
│           └── Add to SubProperties with nested tree (IsClass=true)
│
└── Return complete tree structure


INTERACTIVITY:
==============

Edit Mode Toggle:
├── ToggleEditButton() → IsEditingAllowed = !IsEditingAllowed
└── SetEditFlagRecursive() → walks entire tree setting IsEditable on all nodes

Value Changes:
├── SetValue() called on input change
├── Handles type conversion (enums, numbers, dates, etc.)
├── Updates PropertyValue immediately for UI reflection
└── Invokes ValueSetCallback → OnValueSetCallback()

Property Change Callback:
└── PropertySetValueCallback emits PropertyChangedInfoNotificationInfoPayload with:
    ├── FieldName
    ├── FullPropertyPath
    ├── Value
    └── ValueType (text, boolean, number, date, enum)

Expand/Collapse:
└── ToggleExpandButton() → JavaScript: blazorPropertyGrid.toggleExpandButton()
    └── Toggles Bootstrap collapse class on nested div

🔧 Component Descriptions and Roles

1. PropertyGridComponent (PropertyGridComponent.razor + PropertyGridComponent.razor.cs)

Role: Root container and orchestrator for the entire property grid UI

Primary Responsibility:

Accepts a data object and transforms it into a hierarchical property structure

Key Functions:

  • OnParametersSet() - Initializes the component when parameters are passed
  • MapPropertiesOfDataContext() - Recursively walks the object graph and builds the HierarchicalPropertyInfo tree
  • IsNestedProperty() - Determines if a property should be expanded (nested classes)
  • ToggleEditButton() - Handles the edit mode button click (pencil icon in header)
  • SetEditFlag() / SetEditFlagRecursive() - Propagates the IsEditable flag through the entire tree
  • OnValueSetCallback() - Listens for value changes and emits PropertySetValueCallback events to the parent component

Parameters:

  • DataContext (object) - The root object to display
  • ObjectTitle (string) - Display title for the grid
  • IsEditingAllowed (bool) - Whether fields are editable
  • PropertySetValueCallback (EventCallback) - Callback when a property value changes

Rendering:

Header table with Property/Value columns and edit button. Body table containing rows for top-level properties (delegates nested properties to PropertyRowComponent)

2. PropertyRowComponent (PropertyRowComponent.razor + PropertyRowComponent.razor.cs)

Role: Recursive component that renders individual property rows and handles nested object expansion

Primary Responsibility:

Display a single level of properties and recursively display nested levels

Key Functions:

  • SetValue() - Handles input change events, performs type conversion, and invokes callbacks
  • ToggleExpandButton() - JS interop to toggle Bootstrap collapse classes for expand/collapse UI
  • Property type detection - Conditionally renders different input controls based on property type

Type Support:

  • DateTime → InputDate (HTML5 datetime-local)
  • bool → InputCheckbox
  • int → InputNumber
  • double, decimal, float → InputText with type="number"
  • string → InputText with type="text"
  • Enum → HTML select dropdown with enum values
  • Nested Classes → Recursive PropertyRowComponent call with Depth+1
  • Unknown Types → Raw span display

Parameters:

  • PropertyInfoAtLevel (HierarchicalPropertyInfo) - The current property node to render
  • Depth (int) - Nesting depth for styling and collapse behavior
  • DisplayedFullPropertyPaths (List<string>) - Tracks which paths have been rendered (prevents duplication)

Features:

  • Editable/Read-only modes based on IsEditable flag
  • Collapse/expand functionality for nested objects
  • Visual styling (beige background for read-only values)
  • Full property path as tooltip for clarity

3. HierarchicalPropertyInfo (HierarchicalPropertyInfo.cs)

Role: Data structure representing a single property node in the object hierarchy

Primary Responsibility:

Act as a node in a tree structure that mirrors the original object's property graph

Properties:

  • PropertyName (string) - Name of the property
  • PropertyValue (object) - Current value of the property
  • PropertyType (Type) - CLR type of the property
  • SubProperties (Dictionary<string, HierarchicalPropertyInfo>) - Child properties (for nested objects)
  • FullPropertyPath (string) - Dot-separated path from root (e.g., "Customer.Address.Street")
  • IsClass (bool) - Whether this represents a class type (true = nested object, false = leaf value)
  • IsEditable (bool) - Whether the property can be edited in the current UI state
  • NewValue (object) - Tracks modified value before submission
  • ValueSetCallback (EventCallback) - Callback when this property's value changes

4. PropertyChangedInfoNotificationInfoPayload (PropertyChangedInfoNotificationInfoPayload.cs)

Role: Payload object that carries change notification information back to the parent

Primary Responsibility:

Communicate property change events with detailed context

Properties:

  • FieldName (string) - Name of the property
  • FullPropertyPath (string) - Complete path to the property
  • Value (object) - New value
  • ValueType (string) - Type of value ("text", "number", "boolean", "date", "enum")

🔄 Component Interaction Flow

User interacts with PropertyGrid
    ↓
PropertyGridComponent receives DataContext
    ↓
MapPropertiesOfDataContext() builds tree of HierarchicalPropertyInfo
    ↓
Renders PropertyRowComponent for each top-level property
    ↓
PropertyRowComponent renders based on type:
  ├─ Simple types → InputControl (text, number, checkbox, date, select)
  └─ Nested classes → Recursive PropertyRowComponent
    ↓
User edits a value
    ↓
PropertyRowComponent.SetValue() processes input
    ↓
ValueSetCallback invoked
    ↓
OnValueSetCallback() determines value type and emits PropertySetValueCallback
    ↓
Parent component receives PropertyChangedInfoNotificationInfoPayload

🎯 Key Design Patterns

1. Recursive Composition

PropertyRowComponent calls itself recursively for nested objects, allowing unlimited nesting depth.

2. Tree Structure

HierarchicalPropertyInfo forms a tree that mirrors the object graph, enabling efficient traversal and state management.

3. Event Cascading

Value changes propagate up through callbacks, maintaining separation of concerns between components.

4. Type-Driven Rendering

PropertyRowComponent dynamically renders different input controls based on CLR type, supporting datetime, enum, numeric, boolean, and string types.

5. Bootstrap Collapse Integration

Nested objects use Bootstrap's collapse classes for expand/collapse functionality, toggled via JavaScript interop.


📊 Data Flow Summary

DataContext (Object)
    ↓
MapPropertiesOfDataContext() [Reflection-based tree building]
    ↓
HierarchicalPropertyInfo Tree
    ↓
PropertyGridComponent → PropertyRowComponent Chain [Rendering]
    ↓
HTML Tables + Form Controls
    ↓
[User edits value]
    ↓
ValueSetCallback Events [Bubbling up]
    ↓
PropertyChangedInfoNotificationInfoPayload [Event payload]
    ↓
Parent Component [Handles business logic]

Sunday, 21 December 2025

Finding out file extension from byte inspection

Consider a byte array stored in a column in a table in a database column. How can we identify the file extension of the byte array by inspecting the byte array itself?
Note that byte arrays could be saved many places, also within files or similar.
The extension of a file can be discovered by inspect the File header. This is the first bytes, usually the first tens or hundreds of bytes of the byte array and constitute the file header. Some extensions got multiple file headers. A best effort to identity byte contents of a column in a database.

Let's use Powershell to inspect a file on disk, a sample JPEG file (.jpg). Lets run the following little script:

format-hex .\Stavkyrkje_Røldal.jpg | Select-Object -First 16 The first few bytes are FF D8 FF
I have added a sample Github repo with utility code to check well-known file types for their file extensions.

https://github.com/toreaurstadboss/FileHeaderUtil

The following screenshot shows the application in use. It found out that a byte array seems to be a PDF file by looking at the file header and file trailer. A good match was found :


In fact, a very good match, since both the header and the trailer fully agrees. Note that the 0A bytes are just padding bytes at the end of files and ignored in this util. See the method NormalizeHex presented further below.

Using Gary Kessler`s assembled lists of known file headers and trailers for well-known file types

The util class below shows the helper methods that inspects a byte array and evalues the file header and file trailer against a list of known such headers and trailers.

It bases a compilation of known file headers and file trailers known as "Magic Numbers", compiled by Gary Kessler during the years. In all, 600+ known file types are checked against to classify the matching file extension. Please note that there are cases where multiple matches exists of file header and file trailers matching the given byte array. The matches are sorted by number of matching bytes. The assembled list is very helpful. Thanks, Gary !

Using the file header and also possibly the last bytes of a byte array, the file trailer, we can classify the file type we have in the byte array, i.e. file extension is also implied here by recognizing the file array.

Of course, if one is allowing byte array to be uploaded from a public site for example, it still would be possible to inject malicious bytes, but being able to detect the kind of file is useful both concerning security policies and also determine if the bytes should be handled by an external application or provide information to the end-user what kind of file we have provided a path to for this util.

The curated list of file headers is based upon the list of signatures gathered by Gary Kessler and published on his website here (license of that file is not stated and considered public as it is publicly available information on his website not marked with a license):

https://www.garykessler.net/library/file_sigs.html

This list contains about 650 file types and should cover most of the wellknown formats, including formats not being used so often anymore. If you want to augment the list, check other sources such as Wikipedia if there is information about the given file extension's file header and/or file trailer, so-called "Magic number".

The curated list was updated 3rd June 2023 and contains most well-known file types.

The program uses the file signatures (Json format) to identity the file types of a byte array. Most usually, this is judged by looking at the first few bytes of the file (the so-called "magic numbers"). Sometimes, the file signature may also include bytes from the end of the file (the "trailer").


FileSignatureUtil.cs



using System;
using System.Collections.Generic;
using System.Text;

namespace FileHeaderUtil;


public static class FileSignatureUtil
{

    static FileSignature[] _fileSignatures = [];

    static FileSignatureUtil()
    {
        string json = File.ReadAllText("file_Sigs.json");
        var fileSignaturesRoot = System.Text.Json.JsonSerializer.Deserialize<FileSignatureRootElement>(json, new System.Text.Json.JsonSerializerOptions
        {
            PropertyNameCaseInsensitive = true
        });
        _fileSignatures = fileSignaturesRoot?.FileSigs?.ToArray()!;
    }

    /// <summary>
    /// Scans the specified file and returns a list of file signatures that match the file's header and, if applicable,
    /// file's trailer.
    /// </summary>
    /// <remarks>Only file signatures with a defined header are considered for matching. Trailer matching is
    /// performed if both the file and the signature define a trailer. A header and trailer of 64 bytes is evaluted to also 
    /// detect file types / extensions with longer headers and trailers.</remarks>
    /// <param name="targetFile">The path to the file to be analyzed. Cannot be null or empty.</param>
    /// <param name="byteCount">The number of bytes to read from the file for signature matching. Defaults to 64.</param>
    /// <param name="offset">The byte offset at which to begin reading the file for signature matching. Defaults to 0.</param>
    /// <param name="origin">Specifies the reference point used to obtain the offset. Defaults to <see cref="SeekOrigin.Begin"/>.</param>
    /// <returns>A list of <see cref="FileSignature"/> objects that match the file's header and trailer. The list is empty if no
    /// signatures match.</returns>

    public static List<FileSignature> GetMatchingFileSignatures(string targetFile, int byteCount = 64, int offset = 0, SeekOrigin origin = SeekOrigin.Begin)
    {
        static string NormalizeHex(string? hex, bool trimPadding)
        {
            if (string.IsNullOrWhiteSpace(hex))
            {
                return string.Empty;
            }           

            var parts = hex.Replace("-", " ").Split(new[] { ' ', }, StringSplitOptions.RemoveEmptyEntries)
                           .Select(h => h.ToUpperInvariant())
                           .ToList();

            if (trimPadding)
            {
                while (parts.Count > 0 && (parts.Last() == "0A" || parts.Last() == "0D" || parts.Last() == "00"))
                {
                    parts.RemoveAt(parts.Count - 1);
                }
            }

            return string.Join(" ", parts);
        }

        var matches = new List<(FileSignature Sig, int Score)>();

        string fileHeader = NormalizeHex(FileUtil.ShowHeader(targetFile, offset: 0), trimPadding: false);
        string fileTrailer = NormalizeHex(FileUtil.ShowTrailer(targetFile), trimPadding: true);

        foreach (var signature in _fileSignatures)
        {
            if (string.IsNullOrWhiteSpace(signature?.HeaderHex) || signature.HeaderHex == "(NULL)")
                continue;

            string sigHeader = NormalizeHex(signature.HeaderHex, trimPadding: false);
            string sigTrailer = NormalizeHex(signature.TrailerHex, trimPadding: true);

            if (!fileHeader.StartsWith(sigHeader, StringComparison.OrdinalIgnoreCase))
                continue;

            // Trailer check if defined
            if (!string.IsNullOrWhiteSpace(sigTrailer) && sigTrailer != "(NULL)")
            {
                if (!fileTrailer.EndsWith(sigTrailer, StringComparison.OrdinalIgnoreCase))
                    continue;
            }

            // Compute match score (# of matching bytes in header and trailer of file)
            int headerScore = CountMatchingPrefix(fileHeader, sigHeader);
            int trailerScore = CountMatchingSuffix(fileTrailer, sigTrailer);
            int scoreMeasuredAsMatchingByteCount = headerScore + trailerScore;
            signature.MatchingBytesCount = scoreMeasuredAsMatchingByteCount;
            signature.MatchingTrailerBytesCount = trailerScore;
            signature.MatchingHeaderBytesCount = headerScore;
            matches.Add((signature, scoreMeasuredAsMatchingByteCount));
        }

        return matches.OrderByDescending(m => m.Score).Select(m => m.Sig).ToList();
    }

    // Helpers
    private static int CountMatchingPrefix(string source, string pattern)
    {
        var srcParts = source.Split(' ');
        var patParts = pattern.Split(' ');
        int count = 0;
        for (int i = 0; i < Math.Min(srcParts.Length, patParts.Length); i++)
        {
            if (srcParts[i].Equals(patParts[i], StringComparison.OrdinalIgnoreCase))
                count++;
            else break;
        }
        return count;
    }

    private static int CountMatchingSuffix(string source, string pattern)
    {
        if (string.IsNullOrWhiteSpace(pattern)) return 0;
        var srcParts = source.Split(' ');
        var patParts = pattern.Split(' ');
        int count = 0;
        for (int i = 0; i < Math.Min(srcParts.Length, patParts.Length); i++)
        {
            if (srcParts[srcParts.Length - 1 - i].Equals(patParts[patParts.Length - 1 - i], StringComparison.OrdinalIgnoreCase))
                count++;
            else break;
        }
        return count;
    }

}





As we can see in the source code of NormalizeHex, ending padding chars are removed at the end, since in some cases, byte arrays (files or byte columns in databases for examples) are padded with certain bytes. Also, upper-case is applied and '-' is replaced by space ' '.

In the example below, a PDF file is scanned with the console app and the PDF file header and trailer is recognized. In this case, we also peel of trailing bytes at the end, as the specific PDF file had trailing bytes of pad bytes, more specifically : 0A.

FileUtil.cs

The util class here is used to load a file header or file trailer, a smaller byte array usually. 64 bytes is default evaluated here and should cover most file types file headers and file trailers, actually most file types only has 8 bytes or even less as a file header or file trailer.


namespace FileHeaderUtil
{

    /// <summary>
    /// Helper class for file operations
    /// </summary>
    public static class FileUtil
    {

        /// <summary>
        /// Prints the file header HEX representation
        /// </summary>
        /// <param name="filePath"></param>
        /// <param name="byteCount">Read the first n bytes. Defaults to 64 bytes.</param>
        /// <returns></returns>
        public static string? ShowHeader(string filePath, int byteCount = 64, int offset = 0)
        {
            if (!File.Exists(filePath))
            {
                throw new FileNotFoundException(filePath);
            }

            byte[] header = ReadBytes(filePath, byteCount, offset, SeekOrigin.Begin);
            if (header == null)
            {
                return null;
            }
            return BitConverter.ToString(header);
        }

        /// <summary>
        /// Prints the file trailer HEX representation
        /// </summary>
        /// <param name="filePath"></param>
        /// <param name="byteCount">Read the last n bytes. Defaults to 64 bytes.</param>
        /// <returns></returns>
        public static string? ShowTrailer(string filePath, int byteCount = 64, int offset = 0)
        {
            if (!File.Exists(filePath))
            {
                throw new FileNotFoundException(filePath);
            }

            byte[] header = ReadBytes(filePath, byteCount, offset, SeekOrigin.End);
            if (header == null)
            {
                return null;
            }
            return BitConverter.ToString(header);
        }

        /// <summary>
        /// Reads the n bytes of a byte array. Either from the start or the end of the byte array.
        /// </summary>
        /// <param name="filePath">File path of target file to read the byets</param>
        /// <param name="byteCount">The number of bytes to read</param>
        /// <param name="offset">Offset - number of bytes</param>
        /// <param name="origin">Origin to seek from. Can be either SeekOrigin.Begin, SeekOrigin.Current or SeekOrigin.End</param>
        /// <returns></returns>
        private static byte[] ReadBytes(string filePath, int byteCount, int offset = 0, SeekOrigin origin = SeekOrigin.Begin)
        {
            if (!File.Exists(filePath))
            {
                throw new FileNotFoundException(filePath);
            }

            if (byteCount < 1)
            {
                return Array.Empty<byte>();
            }
            byte[] buffer = new byte[byteCount];
            using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
            if (origin == SeekOrigin.Begin && offset > 0)
            {
                fileStream.Seek(offset, origin);
            }
            else if (origin == SeekOrigin.End)
            {
                fileStream.Seek(-1 * Math.Abs(offset+byteCount), origin);
            }
            else
            {
                //origin must be Current - offset is expected from the current position, just like SeekOrigin.Begin
                fileStream.Seek(offset, origin); 
            }

            int bytesRead = fileStream.Read(buffer, 0, byteCount); 
            if (bytesRead < byteCount)
            {
                Array.Resize(ref buffer, bytesRead);
            }
            return buffer;
        }        
        
    }

}


This console app will only consider max three matching file headers/trailers in cases where multiple such byte array pairs matches a given byte array of a file. To adjust this, see in Program.cs and adjust the Take parameter. Matches are ordered by number of bytes matching.

Monday, 24 November 2025

Exploring Extension Blocks and Constants in C# 14

Extension blocks - Extension properties

Extension blocks and an important new feature - extension properties can be made in C#14. This is available with .NET 10.

It is not possible to define a generic extension block to add extension properties / members (yet, as of C#14 anyways -maybe for future version of C#..)

Consider this example of some well-known constants from entry-level Calculus using extension properties.


using System.Numerics;

namespace Csharp14NewFeatures
{
    /// <summary>
    /// Provides well-known mathematical constants for any numeric type using generic math.
    /// </summary>
    /// <typeparam name="T">A numeric type implementing INumber<T> (e.g., double, decimal, float).</typeparam>
    public static class MathConstants<T> where T : INumber<T>
    {
        /// <summary>π (Pi), ratio of a circle's circumference to its diameter.</summary>
        public static T Pi => T.CreateChecked(Math.PI);

        /// <summary>τ (Tau), equal to 2π. Represents one full turn in radians.</summary>
        public static T Tau => T.CreateChecked(2 * Math.PI);

        /// <summary>e (Euler's number), base of the natural logarithm.</summary>
        public static T E => T.CreateChecked(Math.E);

        /// <summary>φ (Phi), the golden ratio (1 + √5) / 2.</summary>
        public static T Phi => T.CreateChecked((1 + Math.Sqrt(5)) / 2);

        /// <summary>√2, square root of 2. Appears in geometry and trigonometry.</summary>
        public static T Sqrt2 => T.CreateChecked(Math.Sqrt(2));

        /// <summary>√3, square root of 3. Common in triangle geometry.</summary>
        public static T Sqrt3 => T.CreateChecked(Math.Sqrt(3));

        /// <summary>ln(2), natural logarithm of 2.</summary>
        public static T Ln2 => T.CreateChecked(Math.Log(2));

        /// <summary>ln(10), natural logarithm of 10.</summary>
        public static T Ln10 => T.CreateChecked(Math.Log(10));

        /// <summary>Degrees-to-radians conversion factor (π / 180).</summary>
        public static T Deg2Rad => T.CreateChecked(Math.PI / 180.0);

        /// <summary>Radians-to-degrees conversion factor (180 / π).</summary>
        public static T Rad2Deg => T.CreateChecked(180.0 / Math.PI);
    }

    /// <summary>
    /// Extension blocks exposing math constants as properties for common numeric types.
    /// </summary>
    public static class MathExtensions
    {
        extension(double source)
        {
            /// <inheritdoc cref="MathConstants{T}.Pi"/>
            public double Pi => MathConstants<double>.Pi;
            public double Tau => MathConstants<double>.Tau;
            public double E => MathConstants<double>.E;
            public double Phi => MathConstants<double>.Phi;
            public double Sqrt2 => MathConstants<double>.Sqrt2;
            public double Sqrt3 => MathConstants<double>.Sqrt3;
            public double Ln2 => MathConstants<double>.Ln2;
            public double Ln10 => MathConstants<double>.Ln10;
            public double Deg2Rad => MathConstants<double>.Deg2Rad;
            public double Rad2Deg => MathConstants<double>.Rad2Deg;
        }

        extension(decimal source)
        {
            public decimal Pi => MathConstants<decimal>.Pi;
            public decimal Tau => MathConstants<decimal>.Tau;
            public decimal E => MathConstants<decimal>.E;
            public decimal Phi => MathConstants<decimal>.Phi;
            public decimal Sqrt2 => MathConstants<decimal>.Sqrt2;
            public decimal Sqrt3 => MathConstants<decimal>.Sqrt3;
            public decimal Ln2 => MathConstants<decimal>.Ln2;
            public decimal Ln10 => MathConstants<decimal>.Ln10;
            public decimal Deg2Rad => MathConstants<decimal>.Deg2Rad;
            public decimal Rad2Deg => MathConstants<decimal>.Rad2Deg;
        }

        extension(float source)
        {
            public float Pi => MathConstants<float>.Pi;
            public float Tau => MathConstants<float>.Tau;
            public float E => MathConstants<float>.E;
            public float Phi => MathConstants<float>.Phi;
            public float Sqrt2 => MathConstants<float>.Sqrt2;
            public float Sqrt3 => MathConstants<float>.Sqrt3;
            public float Ln2 => MathConstants<float>.Ln2;
            public float Ln10 => MathConstants<float>.Ln10;
            public float Deg2Rad => MathConstants<float>.Deg2Rad;
            public float Rad2Deg => MathConstants<float>.Rad2Deg;
        }
    }
}

We must define extension blocks per type here.

If we move over to extension methods, we still must use a non-generic class. However, we can use for example generic math, show below. This allows to reuse code accross multiple types, supporting INumber<T> in this case.


namespace Csharp14NewFeatures
{
    using System;
    using System.Numerics;

    namespace Csharp14NewFeatures
    {
        /// <summary>
        /// Provides generic mathematical constants via extension methods for numeric types.
        /// </summary>
        public static class MathConstantExtensions
        {
            public static T GetPi<T>(this T _) where T : INumber<T> =>
                T.CreateChecked(Math.PI);

            public static T GetTau<T>(this T _) where T : INumber<T> =>
                T.CreateChecked(2 * Math.PI);

            public static T GetEuler<T>(this T _) where T : INumber<T> =>
                T.CreateChecked(Math.E);

            public static T GetPhi<T>(this T _) where T : INumber<T> =>
                T.CreateChecked((1 + Math.Sqrt(5)) / 2);

            public static T GetSqrt2<T>(this T _) where T : INumber<T> =>
                T.CreateChecked(Math.Sqrt(2));

            public static T GetSqrt3<T>(this T _) where T : INumber<T> =>
                T.CreateChecked(Math.Sqrt(3));

            public static T GetLn2<T>(this T _) where T : INumber<T> =>
                T.CreateChecked(Math.Log(2));

            public static T GetLn10<T>(this T _) where T : INumber<T> =>
                T.CreateChecked(Math.Log(10));

            public static T GetDeg2Rad<T>(this T _) where T : INumber<T> =>
                T.CreateChecked(Math.PI / 180.0);

            public static T GetRad2Deg<T>(this T _) where T : INumber<T> =>
                T.CreateChecked(180.0 / Math.PI);
        }
    }
}

Example usage of the code above :


  #region Extension metmbers using block syntax - Math

  //Extension properties 
  double radians = double.Pi / 3.0; // Pi/3 radians = 60 degrees (1 * Pi = 180 degrees) 
  double degrees = radians * radians.Rad2Deg; // Using the extension method Rad2Deg

  Console.WriteLine($"Radians: {radians:F6}"); //outputs 1.04719..
  Console.WriteLine($"Degrees: {degrees:F6}"); //outputs 60

  //Using Extension methods 

    //Using Extension methods 

  double radiansV2 = 1.0.GetPi() / 3.0;
  double degreesV2 = radians * 1.0.GetRad2Deg();

  Console.WriteLine($"Radians: {radiansV2:F6}");
  Console.WriteLine($"Degrees: {degreesV2:F6}");

Output of the code usage above:


Radians: 1,047198
Degrees: 60,000000
Radians: 1,047198
Degrees: 60,000000

So to sum up, if you use extension blocks in C#, you can use them together with generics, but the extension block must be defined to a concrete type, not using generics. This will result in some cases in lengthier code, as we cannot use generics as much as extension methods allows. Note that extension methods also must be defined inside a non-generic class.