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