Введение

Небольшая статья об алгебраических типах данных и их суррогатах в C#.

Что означает этот термин?

Термин алгебраические типы данных пришёл из функциональной парадигмы.
В .NET экосистеме он предствален в F# и называется Discriminated unions.
Кроме F#, такие же типы существуют в Scala, Haskell, и других функциональных языках.
Кроме того многие языки мз парадигмы ООП реализовали свои варианты алгебраических типов данных.
К примеру, в Kotlin есть sealed classes, а в Java - sealed interfaces.

А что насчёт C#? В нём всё ещё нет нативной реализации, но разработка началась в этом году после многих лет переноса предложения на следующий год.
Но уже сейчас есть несколько библиотек от .NET сообщества эмулирующих поведение DU.

Что это вообще такое ваши DU?

Если вы всё ещё не знакомы с термином Discriminated Unions, то мы идём к вам проще всего понять их суть прочитав документацию на Microsoft Learn по F# Discriminated Unions.
Я бы описал DU как тип, который объединяет несколько вариантов, но при этом экземпляр данного типа может выражаться только в одном варианте.
Это как если бы C# enum имел бы свойства (поля), или ограничиться только одним уровнем наследования.
Проще всего начать с примера на F#. К примеру, нам нужно создать банковский счёт и обработать платёж на основании этого банковского счёта.
В современном мире международный банковский счёт может быть представлен в нескольких системах, к примеру IBAN и SWIFT.

type BankAccountCommonData = { Title: string; BankName: string; BankAddress: string }

type BankAccount =
    | Iban of common: BankAccountCommonData * Number: string
    | Swift of common: BankAccountCommonData * Code: string
    // Uncomment this line to get an warring
    // | Routing of common: BankAccountCommonData * Routing: string

type BankAccountServiceDu =
    static member CreateBankAccount(): BankAccount =
        let number = ""
        let commonData = { Title = ""; BankName = ""; BankAddress = "" }
        Iban(commonData, number)

    static member ProcessPayment (payment: string, account: BankAccount) : string =
        let ProcessIban (payment: string, accountData: BankAccountCommonData, iban: string) =
            // Logic here
            String.Empty

        let ProcessSwift (payment: string, accountData: BankAccountCommonData, swift: string) =
            // Logic here
            String.Empty

        match account with
        | Iban(common, number) -> ProcessIban(payment, common, number)
        | Swift(common, code) -> ProcessSwift(payment, common, code)

Данный F# код выдаст ошибку если убрать комментарий с третьего варианта Routing в типе BankAccount - Warning FS0025 : Incomplete pattern matches on this expression.
Это указывает на то, что в switch покрыты не все варианты.
Этот тип предупреждения может быть переведён в ошибку, что не даст коду скомпилироваться и заставит программиста добавить обработчик на каждый вариант DU, но об этому чуть позже.

И для чего нужно?

Ответ простой и вытекает из описания выше - представить ветвящуюся логику или данные.
Наиболее близкий способ реализовать подобное поведение в C# это наследование.
Пример переведённый выше переписанный на C# с использованием наследования:

internal abstract record BankAccountBase
{
    public required string Title { get; init; }
    public required string BankName { get; init; }
    public required string BankAddress { get; init; }
}

internal sealed record IbanBankAccount(string Number) : BankAccountBase;

internal sealed record SwiftBankAccount(string Code) : BankAccountBase;

internal static class BankAccountServiceInheritance
{
    internal static BankAccountBase GetBankAccount()
    {
        // Logic here

        return new IbanBankAccount("123")
        {
            Title = "T",
            BankName = "A bank",
            BankAddress = "An address"
        };
    }

    internal static void ProcessedPayment(string payment, BankAccountBase bankAccount)
    {
        string ProcessIban(IbanBankAccount iban, string paymentInfo)
        {
            // Logic here
            return string.Empty;
        }

        string ProcessSwift(SwiftBankAccount iban, string paymentInfo)
        {
            // Logic here
            return string.Empty;
        }
        
        var result = bankAccount switch
        {
            IbanBankAccount iban => ProcessIban(iban, payment),
            SwiftBankAccount swift => ProcessSwift(swift, payment),
            _ => throw new ArgumentOutOfRangeException(nameof(bankAccount), typeof(BankAccount).ToString(), null)
        };

        // Logic here
    }
}

"Разве нужно что-то улучшать в этом коде?" вы можете сказать, но я возражу - DU имеют одну очень вашу фичу - exhaustive switch.
F#, Scala, Haskell, Rust, Java, Kotlin, и даже Dart уже имею собственные реализации алгебраических типов данных вместе с exhaustive switch.
"Что значит exhaustive switch?" Значит именно то что написано, на русском это буквально исчерпывающий переключатель.
Это такой switch который требует указать все варианты переменной имеющей конечное множество значений.
Обычно в C# коде мы достигаем исчерпывающего поведения при помощи default arm, где указываем значение по умолчанию или выбрасываем ошибку.
В случае если кто-то добавил новый вариант банковского аккаунта, к примеру на основе Routing number для Британии, и забыл указать этот вариант в switch в методе ProcessedPayment, то код скомпилируется и уже в процессе работы программы вылетит ArgumentOutOfRangeException вместо обработки платежа на основе Routing number.
При использовании exhaustive switch мы можем предотвратить появление такого бага.

C# DU

Мы можем достичь в некоторой степени исчерпывающего switch работая только с функциональностью C# из коробки просто используя record (class or struct), в которой в качестве полей указаны все ветвления данных или логики, сгенерированного компилятором метода deconstruct из record и switch expression.
Взгляните на метод ProcessedPaymentExhaustive ниже, выглядит не очень и читается так себе, но оно работает!
Если кто-то добавить новое свойство в BankCreateResultTuples, то вылетит ошибка компиляции, поскольку сгенерированный компилятором метода deconstruct включает по умолчанию все свойства.

internal abstract record BankAccountBase
{
    public required string Title { get; init; }
    public required string BankName { get; init; }
    public required string BankAddress { get; init; }
}

internal sealed record IbanBankAccount(string Number) : BankAccountBase;

internal sealed record SwiftBankAccount(string Code) : BankAccountBase;

internal readonly record struct BankCreateResultTuples(BankAccount.Iban? Iban, BankAccount.Swift? Swift);

internal static class BankAccountServiceRecord
{
    internal static BankCreateResultTuples GetBankAccount()
    {
        // Logic here

        return new BankCreateResultTuples(
            null,
            new BankAccount.Swift("123")
            {
                Title = "T",
                BankName = "A bank",
                BankAddress = "An address"
            }
        );
    }

    /// <summary>
    /// Non-exhaustive switch
    /// </summary>
    internal static void ProcessedPayment(BankCreateResultTuples bankAccount)
    {
        var result = bankAccount switch
        {
            { Iban: null, Swift: not null } => "",
            { Iban: not null, Swift: null } => "",
            _ => throw new ArgumentOutOfRangeException(nameof(bankAccount), bankAccount, null)
        };

        // Logic here
    }

    /// <summary>
    /// Has some exhaustiveness
    /// </summary>
    internal static void ProcessedPaymentExhaustive(BankCreateResultTuples bankAccount)
    {
        var result = bankAccount switch
        {
            (null, not null) => "",
            (not null, null) => "",
            _ => throw new ArgumentOutOfRangeException(nameof(bankAccount), bankAccount, null)
        };

        // Logic here
    }
}

OneOf

Наиболее распространённый суррогат DU в C# это библиотека OneOf.

internal readonly record struct BankAccountCommonData(string Title, string BankName, string BankAddress);

/// <summary>
/// A first variant of DU
/// </summary>
internal readonly record struct Iban(string Number, BankAccountCommonData CommonData);

/// <summary>
/// A first variant of DU
/// </summary>
internal readonly record struct Swift(string Code, BankAccountCommonData CommonData);

/// <summary>
/// A possible third variant of DU (not in use)
/// </summary>
internal readonly record struct Routing(string Route, BankAccountCommonData CommonData);

internal static class BankAccountServiceOneOf
{
    /// <summary>
    /// Return a DU that is presented by <see cref="OneOf{T0, T1}"/>
    /// </summary>
    internal static OneOf<Iban, Swift> GetBankAccount()
    {
        // Logic here

        var commonData = new BankAccountCommonData("Title", "A bank", "An address");
        return new Iban("Number", commonData);
    }

    /// <summary>
    /// Consume a DU that is presented by <see cref="OneOf{T0, T1}"/>
    /// Add a third generic to the account argument type to get a compiler error.
    /// </summary>
    internal static void ProcessPayment(string payment, OneOf<Iban, Swift> account)
    {
        var result = account.Match(
            iban => ProcessIban(iban, payment),
            swift => ProcessSwift(swift, payment)
        );

        string ProcessIban(Iban iban, string paymentInfo)
        {
            // Logic here
            return string.Empty;
        }

        string ProcessSwift(Swift iban, string paymentInfo)
        {
            // Logic here
            return string.Empty;
        }
    }
}

Оно эмулирует поведение DU используя дженерики и перегрузку типов.
Оно работает в нескольких режимах - можно использовать базовый тип с дженериками или создать свой, то тогда этот должен быть класс.
OneOf<T, ...Tn> это просто обёртка над возвращаемым типом. Это struct, и позволяет использовать как class так struct в качестве дженерик параметров, что может быть полезно если вы озадачены потреблением памяти или аллокациями в куче.
Что бы получить поведение exhaustive switch нужно использовать метод Match из самой структуры, в котором нужно указать метод-обработчик на каждый вариант возвращаемого значения.
Если кто-то добавил новый вариант в возвращаемый тип, то будет использована другая перегрузка типа с большим количеством вариантов, и код не скомпилируется пока в методе Match не будут указаны методы-обработчики на все варианты.
Самый большой недостаток этой библиотеки - монструозные подписи методов, возвращаемый тип может достигать 100 символов, так же нужно оборачивать в Task возвращаемый тип при смешении с синхронными методами.
Так же кто-то может найти недостатком то, что количество вариантов ограничено восемью, но в таком случа лучше пересмотреть логику.
PS: OneOf очень похож на тип Choice из F# (но под капотом этот всё тот же DU).

StaticCs | static-cs

Библиотека static-cs сделает стандартный switch исчерпывающим!

[Closed]
internal abstract record BankAccount
{
    private BankAccount()
    {
    }

    public required string Title { get; init; }
    public required string BankName { get; init; }
    public required string BankAddress { get; init; }

    internal sealed record Iban(string Number) : BankAccount;

    internal sealed record Swift(string Code) : BankAccount;

    // Uncomment this line to get Error CS8509 during compilation.
    // internal sealed record Wire(string Code) : BankAccount;
}

internal static class BankAccountServiceStaticSc
{
    internal static BankAccount GetBankAccount()
    {
        return new BankAccount.Iban("Number")
        {
            Title = "T",
            BankName = "A bank",
            BankAddress = "An address"
        };
    }

    internal static void ProcessPayment(string payment, BankAccount account)
    {
        var result = account switch
        {
            BankAccount.Iban iban => ProcessIban(iban, payment),
            BankAccount.Swift swift => ProcessSwift(swift, payment),
        };

        string ProcessIban(BankAccount.Iban iban, string paymentInfo)
        {
            // Logic here
            return string.Empty;
        }

        string ProcessSwift(BankAccount.Swift iban, string paymentInfo)
        {
            // Logic here
            return string.Empty;
        }
    }
}

StaticCs очень прост в использовании потому что использует Roslyn analyzer и добавляет только один новый аттрибут - Closed, а всё остальное - функционал C# из коробки.
Чтобы использовать DU вместе и exhaustive switch, нам нужно создать базовый абстрактный class (record), сделать конструктор по умолчанию приватным, поместить все производные внутрь класса и пометить его атрибутом Closed.
Один важный момент — в switch нельзя использовать default arm, иначе это уничтожить его исчерпывающие свойства.
Еще одна очень полезная вещь — перевести предупреждение CS8509 в ошибку, чтобы предотвратить компиляцию кода в случае, если switch не охватывает все варианты переменной.
Возьмём всё тот же пример — кто-то добавил новый вариант банковского счета (Routing number для Великобритании) и забыл добавить этот вариант в switch в методе ProcessedPayment.
Теперь код НЕ компилируется, а компилятор выведет ошибку в консоль, и разработчик будет вынужден добавлять методы для обработки всех производных от базового класса.
Существуют и другие библиотеки для эмуляции поведения DU, но я предпочитаю StaticCs, потому что она имеет меньше внешних зависимостей и использует родной C# функционал.

PS: Выглядит знакомо?) Эта конструкция очень похожа на Kotlin sealed classes.

sealed class BankAccount {
    abstract val title: String
    abstract val bankName: String
    abstract val bankAddress: String

    data class Iban(
        val number: String,
        override val title: String,
        override val bankName: String,
        override val bankAddress: String
    ) : BankAccount()

    data class Swift(
        val code: String,
        override val title: String,
        override val bankName: String,
        override val bankAddress: String
    ) : BankAccount()
}

fun createPayment(): BankAccount {
    // Logic here
    return BankAccount.Iban(number = "Number", title = "Title", bankName = "A bank", bankAddress = "An address")
}

fun processPayment(payment: String, account: BankAccount): String {

    fun processIban(iban: BankAccount.Iban, paymentInfo: String): String {
        // Logic here
        return ""
    }

    fun processSwift(swift: BankAccount.Swift, paymentInfo: String): String {
        // Logic here
        return swift.bankName
    }

    return when (account) {
        is BankAccount.Iban -> processIban(account, payment)
        is BankAccount.Swift -> processSwift(account, payment)
    }
}

Это замена Exception?

И да и нет.
Я считаю, что этот способ гораздо лучше подходит для описания бизнес-логики, чем сру выкидывание Exception, поскольку он явный и не зависит от блока try-catch, но для библиотечного или инфраструктурного кода, исключения могут подойти лучше.
Всегда нужно помнить - исключения и DU это только инструменты, а у каждого инструмента есть своя область применения.

Комментарии (16)


  1. dyadyaSerezha
    22.10.2025 03:37

    нужно оборачивать в Task возвращаемый тип при смешении с синхронными методами.

    С синхронными или асинхронными?

    Также, лучше поставить тэг языка непосредственно перед каждым примером кода, потому что иногда это не очевидно.

    Также, не очень хорошее название метода: GetBankAccount(). Метод создаёт аккаунт, а не просто возвращает его. Значит, лучше назвать CreateBankAccount. Мелочь, но всё же)


  1. Naf2000
    22.10.2025 03:37

    Спасибо за статью, есть вопрос.

    Не кажется ли все это антипаттерном? Как только у нас появится новый вид счета, мы будем вынуждены пройтись по всему коду и добавить во все switch новую ветку. А в случае с StaticCs еще и непосредственно в BankAccount.

    Почему не использовать интерфейс или абстрактный класс с абстрактным методом? Например так:

    interface IBankAccount
    {
      void ProcessPayment(string payment);
    }
    
    public class SwiftAccount: IBankAccount
    {
      public void ProcessPayment(string payment)
      {
        //...
      }
    }

    При этом все типы, которые пожелают быть IBankAccount должны будут реализовать этот интерфейс.


    1. Deosis
      22.10.2025 03:37

      Зависит от количества и разнообразия операций. Например, может потребоваться добавить на веб страницу различные иконки в зависимости от типа аккаунта. Такой метод придется тянуть в интерфейс.


    1. QweLoremIpsum
      22.10.2025 03:37

      Не кажется ли все это антипаттерном? Как только у нас появится новый вид счета, мы будем вынуждены пройтись по всему коду и добавить во все switch новую ветку

      Это не баг, это фича! Серьезно, это же замечательно что как только появится новый вид счета программист обязан пройтись по всем свитчам и точно не забудет обработать новый кейс, в f# можно даже сделать не законченный свитч это ошибкой компиляции!
      Интерфейс тут не очень хорош, представим что вам нужно выводить на сайте этот объект и в зависимости от типа аккаунта отображение должно быть разным, в этом случае нам придется логику отображения класть в класс SwiftAccount , во-первых она там ни к месту, во-вторых логика отображения будет раскидана по разным классам, затем появится требование отображать его при печати на бумаге, опять придется класть логику отображения в классы аккаунта


      1. Naf2000
        22.10.2025 03:37

        Да, согласен пихать все в класс как бы не к месту. А что если воспользоваться паттерном visitor? Да, его придется поддерживать постоянно в рабочем состоянии при добавлении новых подклассов, но кажется это альтернатива:

        interface IBankAccount
        {
          void Accept(IBankAccountVisitor visitor);
        }
        
        public class SwiftAccount: IBankAccount
        {
          public void Accept(IBankAccountVisitor visitor)
          {
            visitor.SwiftVisit(this);
          }
        }
        
        interface IBankAccountVisitor
        {
          void Visit(IBankAccount account) => account.Accept(this);
          void SwiftVisit(SwiftAccount account);
          //... и все другие
        }
        
        class WebBankAccountPresenter: IBankAccountVisitor
        {
        /// тут специфика
        }
        
        IBankAccount account = new SwiftAccount();
        IBankAccountVisitor visitor = new WebBankAccountPresenter();
        visitor.Visit(account);

        Из минусов - можно умышленно не делать настоящую реализацию Accept. Но это кажется вредительство


        1. QweLoremIpsum
          22.10.2025 03:37

          Вам придется заводить новый класс буквально каждый раз когда вы используете переменную типа IBankAccount. А в банк аккаунте скорее всего будут еще вложенные DU например Owner (напр. частное лицо или юр лицо (если юр лицо то ООО или ИП и тд и тп))


          1. Naf2000
            22.10.2025 03:37

            Новый класс Visitor придется заводить под задачу. Отображения на форме и т.д. Ну так в этом его предназначение и есть.

            Это всего лишь идея попытаться уложить решение в стандартные паттерны.


    1. Vanirn Автор
      22.10.2025 03:37

      Не кажется ли все это антипаттерном?

      DU и подобные решения изобрели не вчера - такой подход уже очень давно существуют в функциональных языках, и даже относительно новый Rust использует DU :)

      мы будем вынуждены пройтись по всему коду и добавить во все switch новую ветку

      Вы не уловили суть такого подхода. Обрабатывать все варианты в каждом switch это и есть цель такого подхода.

      Почему не использовать интерфейс или абстрактный класс с абстрактным методом?

      Вы предлагаете отказаться от anemic model (только хранят данные), как в моих примерах, и перейти к reach model (моделям которых хранять своё состояние и имеют действия которые меняют это состояние). Или же путаете stateless services, которые не хранят данные, а только действия по преобразованию данных, и value types которые только хранять данные. В данных примерах BankAccount это анемичная модель.

      Reach model это другой подход, более ООПшный, но уже редко используемый в современнои .NET. Хотя акторная модель, тот же Orleans может работать практически только с reach model.


  1. Cryvage
    22.10.2025 03:37

    Запрещаем наследоваться от BankAccount, делая конструктор приватным. Record в этом плане дырявый, поэтому только класс. Наследники могут быть только вложенными классами и они оба sealed. Пишем свой метод для исчерпывающего свича (две перегрузки, с возвращаемым значением и без). На нативный свич полагаться нет смысла, т.к. он не поддерживает исчерпывающий свич (частично работает для enum, но и то через задницу). Поэтому, единственный верный вариант - написать свой метод. Дефолтная ветка с Exception добавлена для подавления предупреждений. По идее, она никогда не должна выполниться. Навероне можно умудриться в неё зайти с помощью рефлексии.

    public abstract class BankAccount
    {
        private BankAccount()
        {
            Title = "";
            BankName = "";
            BankAddress = "";
        }
        public required string Title { get; init; }
        public required string BankName { get; init; }
        public required string BankAddress { get; init; }
        
        public sealed class Iban : BankAccount
        {
            public required string Number { get; init; }
        }
    
        public sealed class Swift : BankAccount
        {
            public required string Code { get; init; }
        }
    
        public R ExhaustiveSwitch<R>(Func<Iban, R> ibanCase, Func<Swift, R> swiftCase)
        {
            return this switch
            {
                Iban iban => ibanCase(iban),
                Swift swift => swiftCase(swift),
                _ => throw new ArgumentOutOfRangeException("this", GetType().ToString(), "This should not happen. Are you using reflection?")
            };
        }
    
        public void ExhaustiveSwitch(Action<Iban> ibanCase, Action<Swift> swiftCase)
        {
            switch(this)
            {
                case Iban iban: ibanCase(iban);
                    break;
                case Swift swift: swiftCase(swift);
                    break;
                default: throw new ArgumentOutOfRangeException("this", GetType().ToString(), "This should not happen. Are you using reflection?");
            }
        }
    }

    Используем вот так:

    BankAccount account = new BankAccount.Swift()
    {
        Title = "Тайтл",
        BankName = "Имя Банка",
        BankAddress = "Адрес Банка",
        Code = "0123456789",
    };
    
    
    string ProcessIban(BankAccount.Iban iban, string paymentInfo)
    {
        return $"IBAN: { paymentInfo }";
    }
    
    string ProcessSwift(BankAccount.Swift swift, string paymentInfo)
    {
        return $"SWIFT: { paymentInfo }";
    }
    
    void ProcessIbanConsole(BankAccount.Iban iban, string paymentInfo)
    {
        System.Console.WriteLine( $"IBAN: { paymentInfo }" );
    }
    
    void ProcessSwiftConsole(BankAccount.Swift swift, string paymentInfo)
    {
        System.Console.WriteLine($"SWIFT: { paymentInfo }");
    }
    
    
    var result = account.ExhaustiveSwitch(
        iban => ProcessIban(iban, "Transfer $1000"),
        swift => ProcessSwift(swift, "Transfer $1000")
    );
    System.Console.WriteLine(result);
    
    account.ExhaustiveSwitch(
        iban => ProcessIbanConsole(iban, "Transfer $2000"),
        swift => ProcessSwiftConsole(swift, "Transfer $2000")
    );

    Никаких сторонних библиотек. Никакого шаманства с .editorconfig. Никаких неочевидных танцев с перегрузкой типов. Никакого Roslyn. Пользователь обязан указать лямбды (или методы) для обработки всех случаев, "забыть" ветку не выйдет. Расширить можно только внеся изменения в класс BankAccount, а следовательно, человек, вносящий изменения, обязан будет обновить методы ExhaustiveSwitch и ExhaustiveSwitch<R>. При расширении, старый пользовательский код сломается, до тех пор, пока не добавят обработку новых подклассов во все вызовы ExhaustiveSwitch. В общем, вроде бы все требования удовлетворены. Бойлерплейт конечно присутствует, особенно из-за невозможности использовать здесь record, но много ли таких алгебраических типов надо вам в системе? На мой взгляд, бойлерплейт здесь не критичен, именно по причине того, что таких типов надо не так уж и много. Но может это говорит привычка, а точнее отсутствие привычки такие типы использовать.

    З.Ы. При необходимости, можно даже сделать такие перегрузки ExhaustiveSwitch, в которых какие-то обработчики будут требоваться строго, а другие будут необязательными параметрами. Если сделать перегрузку ExhaustiveSwitch, принимающую специальный класс с нужными делегатами, то можно вообще очень сложную логику накрутить. Ни один язык никогда из коробки такого не даст. Строго говоря, это уже будет и не алгебраический тип, а что-то по мотивам. Зато можно вручную контролировать, какие ветки обрабатывать обязательно, а какие нет.


    1. NightBlade74
      22.10.2025 03:37

      Используем вот так:

      Выглядит жутковато.


    1. Vanirn Автор
      22.10.2025 03:37

      Record в этом плане дырявый

      Эмм, и какие в нём дыры?

      Вообще-то вы можете написать свою библиотеку анальтернативу OneOf, но вряди получится что-то более нативное и локаничное чем StaticSc.


      1. stepagrus
        22.10.2025 03:37

        Под "дырами" record, наверное, товарищ имел ввиду struct, а именно то, что в структуре нельзя переопределить конструктор по умолчанию.


        1. Cryvage
          22.10.2025 03:37

          Не совсем. Я имел в виду именно record. Но проблема действительно с конструкторами. Раз уж по этому поводу возникло недопонимание, попробую развёрнуто пояснить на примерах. Эх, придётся повоевать с редактором комментариев, чтобы это всё аккуратно оформить.

          Буду использовать пример с BankAccount выше, как основу, но для краткости выкину из него методы ExhaustiveSwitch ибо они не имеют отношения к делу.

          Для начала перепишем пример с использованием record вместо class.

          Скрытый текст
          public abstract record BankAccount(string Title, string BankName, string BankAddress)
          {
              private BankAccount() : this("", "", "") { }
          
              public sealed record Iban(string Title, string BankName, string BankAddress, string Number) : BankAccount(Title,BankName,BankAddress);
              public sealed record Swift(string Title, string BankName, string BankAddress, string Code) : BankAccount(Title,BankName,BankAddress);
          }

          Вроде бы, всё хорошо, конструктор по умолчанию успешно переопределён, а код стал сильно короче, даже без учёта выброшенных методов. Но есть проблемка. Объявление record через синтаксис вида record BankAccount(string Title, string BankName, string BankAddress) приводит к автоматическому созданию конструктора с тремя строковыми праметрами Title, BankName и BankAddress. Переопределить и скрыть его никак нельзя. А следовательно, кто угодно может теперь расширить BankAccount без каких либо препятствий. Например, вот так:

          Просто берём и наследуемся, как будто так и надо
          public record Mir(string Id) : BankAccount ("","","") { }
          //Подразумевается, что запись Mir находится за пределами записи BankAccount

          Конечно, мы можем переписать пример, не используя синтаксис с круглыми скобками, а объявив свойства вручную.

          Объявляем свойства вручную. Лёгким движением, record превращается в "элегантный" class
          public abstract record BankAccount
          {
              private BankAccount() { }
              public required string Title { get; init; }
              public required string BankName { get; init; }
              public required string BankAddress { get; init; }
              public sealed record Iban : BankAccount
              {
                  public required string Number { get; init; }
              }
          
              public sealed record Swift : BankAccount
              {
                  public required string Code { get; init; }
              }
          }

          Но чем такой код будет отличаться от варианта с классом, кроме ключевого слова record? На самом деле, кое чем будет. Дело в том, что для записей автоматически генерируется конструктор копирования. И этот конструктор имеет уровень доступа protected. Иными словами, нет никаких проблем создать наследника от BankAccount, использовав этот автоматически сгенерированный конструктор.

          Используем конструктор копирования, для обхода запрета на наследование
          public record Mir : BankAccount
          {
              public required string Id { get; init; }
          
              [System.Diagnostics.CodeAnalysis.SetsRequiredMembers]
              public Mir() : base(new Iban { BankAddress = "", BankName = "", Number = "", Title = "" })
              { }
          }

          Единственный способ хоть как-то с этим побороться, это переопределить у BankAccount конструктор копирования вручную.

          Переопределяем конструктор копирования
          public abstract record BankAccount
          {
              private BankAccount() { }
          
              [System.Diagnostics.CodeAnalysis.SetsRequiredMembers]
              protected BankAccount(BankAccount bankAccount)
              {
                  throw new Exception("Inheritance is not allowed");
              }
              public required string Title { get; init; }
              public required string BankName { get; init; }
                  public required string BankAddress { get; init; }
              public sealed record Iban : BankAccount
              {
                  public required string Number { get; init; }
              }
          
              public sealed record Swift : BankAccount
              {
                  public required string Code { get; init; }
              }
          }

          Проблема в том, что изменить уровень доступа мы не можем, а можем только кинуть исключение. И если кто-то всё же попробует отнаследоваться от BankAccount, ошибка заметят лишь в рантайме, а вот во время компиляции никаких проблем не будет. Это уж не говоря о том, что вообще-то, recordу желательно иметь нормальный рабочий конструктор копирования. Не зря же компилятор нам его генерирует. Ну и не забываем о том, что от короткого синтаксиса уже пришлось отказаться, а в таком случае, зачем это всё? Класс работал лучше. Даже не так. Он работал идеально, если не считать некоторой многословности. А вот record - дырявый. Хотя, наверное не правильно ругать record. Сандали, вон, тоже дырявые. Но это не баг, и даже не совсем фича. Это их самая суть.

          Резюмируя, можно сказать, что record просто не задуман по своему дизайну, чтобы мы игрались с его конструкторами, запрещали наследование, прятали там что-то и т.д. Это должен быть простой иммутабельный тип с value семантикой, по типу структуры, но только в отличие от структур, аллоцирующийся в куче. Идти против дизайна языка, воюя с тем, что нам автоматически генерирует компилятор - это всегда сомнительная идея. Если есть выбор, то лучше так не делать. Да и вообще, ради чего мы это пытаемся делать? Ради краткости синтаксиса? Серьёзно? Впрочем, даже её мы благополучно потеряли уже на втором примере.

          Немного оффтопа

          В какой-то степени, то же самое можно сказать и про всю идею с использованием DU в C#. Если прям очень нужно, язык нам это позволяет сделать штатными средствами и без сторонних библиотек. Именно это я и хотел показать своим первым комментарием. Несколько громоздко, что наверное отсекает значительную часть use case'ов, но тем не менее, когда надо - можно. И даже без каких-то оговорок. Код получается понятный, логика DU реализована как надо, никаких лишних зависимостей, да и бойлерплейт только на объявляющей стороне, а для пользовательского кода всё удобно. Что это значит на практике? Ну, лично для меня это как раз и значит, что я буду использовать этот подход ровно в тех случаях, когда он будет полезен несмотря на многословность синтаксиса. Что же до клепания DU на каждый чих, то если вам такое надо, то значит вы предпочитаете писать код в функциональном стиле, и на сегодня экосистема .net предоставляет вам аж целый язык F#. И если вы хотите писать в функциональном стиле, то и пишите на нём. Серьёзно, я считаю, что это наилучший вариант. Благо, среда исполнения одна. Можно совместить два языка, если не готовы отказаться от C# полностью. А C# это объектный язык, пусть и с примесями функциональщины. Ничего удивительного, что не все приёмы из ФП полноценно работают. Стоит ли заставлять их работать, подпирая код костылями? Я думаю, что нет. Уж точно не в продакшене. Так же как, например, не стоит пытаться писать в объектном стиле на языках не поддерживающих ООП. По мере развития C# расклад, конечно, может сильно поменяться. За последние годы и так дофига чего поменялось. В интересное время живём.


      1. Cryvage
        22.10.2025 03:37

        По поводу "дыр" развёрнуто ответил на комментарий ниже.

        Что же касается написания библиотеки, то пример как раз был расчитан на то, чтобы показать как это можно делать без всяких библиотек и наиболее прямолинейным способом. Это хорошо подходит для образовательных целей, и как мне показалось, дополнит статью. Если вам нужно немного таких классов, то подход вполне годится и в продакшен. А если нужно много, то можно вообще использовать F#.


  1. benjik
    22.10.2025 03:37

    Ждём нативную поддержку в языке https://github.com/dotnet/csharplang/issues/8928 .

    Вроде как в 2025г обсуждений стало больше, надеюсь в .Net 11 завезут preview, а в .Net 12 LTS можно будет нормально пользоваться.

    Пока что все эти самодельные штуки создают больше головняка чем пользы по сравнению с DU в F#/Rust/etc


    1. Vanirn Автор
      22.10.2025 03:37

      Проблема в том, что никакой даты реализации DU даже и близко нет (:
      А StaticSc это буквально один аттрибут, а остальное - обычный C# с перебором производных типов в обычном switch. Даже если захотите от неё избавиться, то нужно всего лишь удалить [Closed] обычными средствами любого текстового редактора.