Union types в C# 15: от проблем с JSON до строгой типизации
В C# 15, вышедшем вместе с .NET 11, появилась возможность, которой языку довольно давно не хватало, — нативные union types, или объединения типов. Теперь мы можем объявить тип, значение которого представляет один из нескольких заранее определённых вариантов, а компилятор поможет убедиться, что при обработке мы ничего не забыли. Звучит интересно, но зачем это нужно на практике? Ведь в C# уже есть наследование, интерфейсы, object, сопоставление с образцом и различные библиотеки для работы с результатами операций. Разве всего этого недостаточно?
Чтобы ответить на этот вопрос, предлагаю разобрать реальную задачу, с которой я уже сталкивался при разработке. Затем посмотрим, как её можно решить средствами C# 15, и создадим небольшое приложение, которое получает данные из публичного API.
Исходный код примеров доступен в репозитории CSharp15.UnionTypes.Demo. Проект написан на
.NET 11, использует нативные объединения типов и содержит автоматические тесты.
1. Проблема, знакомая по реальным API#
В апреле 2025 года я опубликовал статью «Боремся с union types», посвящённую одной неприятной особенности внешнего API. Проблема заключалась в том, что одно и то же свойство options в ответе сервера могло содержать совершенно разные структуры: массив объектов, объект с настройками, пустой объект или вообще отсутствовать. Для строго типизированного языка это не самый удобный контракт.
Например, сервер мог вернуть:
{
"options": [
{
"is_correct": true,
"text": "Ответ A"
},
{
"is_correct": false,
"text": "Ответ B"
}
]
}
А мог вернуть объект:
{
"options": {
"is_checkbox": true,
"sample_size": 5
}
}
Тогда проблему пришлось решать с помощью класса-обёртки и собственного JsonConverter, который определял тип входящего значения и создавал соответствующую модель. Подход работал, но был не особенно удобен. Поэтому появление нативных union types стало хорошим поводом вернуться к этой теме. Однако вместо повторения старого примера мы воспользуемся другим публичным API, чтобы проверить возможности языка на реальных данных.
2. Знакомимся с Open Library API#
Для эксперимента я выбрал Open Library — открытый каталог книг, предоставляющий бесплатный HTTP API. Для чтения публичных данных не требуется API-ключ.
Получить сведения о произведении можно обычным GET-запросом:
GET https://openlibrary.org/works/OL45804W.json
Ответ содержит название книги, идентификаторы, сведения об авторах и другие метаданные. Нас интересует поле description, поскольку оно может иметь разные представления.
В одном случае описание является строкой:
{
"title": "Example Book",
"description": "A description of the book."
}
В другом — объектом:
{
"title": "Example Book",
"description": {
"type": "/type/text",
"value": "A description of the book."
}
}
Здесь и далее короткие JSON-фрагменты иллюстрируют структуру ответа. Оригинальные ответы сервера сохранены в репозитории отдельно. Кроме того, поле description может отсутствовать или содержать null. Для этих случаев проект использует синтетические тестовые данные, поскольку во время исследования соответствующие реальные записи найдены не были. Получается знакомая ситуация: одно свойство может содержать строку, объект или не содержать полезного значения.
Посмотрим, как такую модель можно описать в C#.
3. Старый подход: несколько nullable-свойств#
До C# 15 одним из возможных решений было создание класса, который содержит все допустимые варианты данных.
В нашем проекте такая модель выглядит следующим образом:
public sealed record LegacyDescription(
string? Text,
LegacyTextObject? Object)
{
// Методы разбора и отображения
}
public sealed record LegacyTextObject(string Value);
При получении строки мы заполняем Text, при получении объекта — Object, а если описание отсутствует, оба свойства остаются равными null.
Для разбора используется JsonElement, позволяющий определить тип входящего значения:
public static LegacyDescription Parse(
JsonElement value) => value.ValueKind switch
{
JsonValueKind.Undefined or JsonValueKind.Null =>
new(null, null),
JsonValueKind.String =>
new(value.GetString()!, null),
JsonValueKind.Object =>
ParseObject(value),
_ => throw new JsonException(
"Unexpected description format.")
};
Метод ParseObject дополнительно проверяет структуру объекта: наличие type, значение /type/text и строковое свойство value. На первый взгляд всё вполне нормально. У нас есть строгая модель, проверка входящих данных и понятный способ получить описание книги.
Но посмотрим на следующее выражение:
var description = new LegacyDescription(
"Обычный текст",
new LegacyTextObject("Текст из объекта"));
С точки зрения компилятора всё корректно. А вот с точки зрения нашей предметной области объект находится в недопустимом состоянии: одновременно заполнены оба варианта описания.
Чтобы обнаружить такую ситуацию, приходится добавлять проверку:
public string Render()
{
if (Text is not null && Object is not null)
throw new InvalidOperationException(
"Both alternatives are set.");
if (Text is not null)
return $"text: {Text}";
if (Object is not null)
return $"object: {Object.Value}";
return "none";
}
Это уже не просто неудобство. Получается, что наша модель допускает состояния, которых не существует в исходном контракте API, а обнаружить ошибку можно только во время выполнения программы. Конечно, мы могли бы написать более сложную обёртку с приватными конструкторами, фабричными методами и дополнительными проверками. Или воспользоваться сторонней библиотекой вроде OneOf. Но теперь у языка есть собственный механизм для решения подобных задач.
4. Нативные union types в C# 15#
В C# 15 появился ключевой термин union.
Начнём с объявления трёх типов:
public sealed record PlainText(string Value);
public sealed record TextObject(string Value);
public sealed record NoDescription;
Они описывают три возможных состояния: обычную строку, объект с текстом и отсутствие описания.
Теперь объявим объединение:
public union Description(
PlainText,
TextObject,
NoDescription);
Вот и всё. Мы создали новый тип Description, значение которого может представлять один из трёх перечисленных вариантов. В отличие от предыдущего решения, здесь нет двух nullable-свойств, которые можно случайно заполнить одновременно. Вместо этого Description содержит одно значение определённого варианта.
Создание значений#
Объединения поддерживают неявное преобразование из перечисленных типов:
Description first =
new PlainText("Обычное описание");
Description second =
new TextObject("Описание из объекта");
Description third =
new NoDescription();
Нам не нужно вручную создавать обёртку или указывать, какое именно свойство должно быть заполнено. Компилятор знает, что PlainText, TextObject и NoDescription входят в объявление Description. Тут важно, что эти типы не наследуются от общего базового класса. Они могут быть совершенно независимыми друг от друга.
Обработка значений через switch#
Теперь получим текст описания:
public static string Render(
Description description) => description switch
{
PlainText text =>
$"text: {text.Value}",
TextObject text =>
$"object: {text.Value}",
NoDescription =>
"none",
null => throw new InvalidOperationException(
"Uninitialized Description union.")
};
Каждая ветка соответствует конкретному варианту объединения. При этом компилятор понимает, какие варианты объявлены в Description, и может проверить, что все они обработаны. И здесь мы подходим к одной из наиболее интересных особенностей новой конструкции.
5. Exhaustiveness checking: компилятор следит за вариантами#
Представим, что через некоторое время внешний API начал возвращать ещё один вариант описания, например форматированный текст.
Мы добавляем новый тип:
public sealed record RichText(
string Value,
string Format);
И расширяем объединение:
public union Description(
PlainText,
TextObject,
NoDescription,
RichText);
Но забываем обновить метод Render. В обычной иерархии классов такая ошибка могла бы проявиться только во время выполнения. Если бы обработчик содержал ветку _, он мог бы просто вернуть универсальное значение или выбросить исключение. С нативным union компилятор способен обнаружить неполную обработку вариантов.
В демонстрационном проекте этот сценарий проверен отдельно. При компиляции неполного switch возникает предупреждение:
CS8509: The switch expression does not handle
all possible values of its input type
После добавления обработки RichText предупреждение исчезает.
RichText rich =>
$"rich ({rich.Format}): {rich.Value}",
Это особенно важно для кода, который развивается со временем. Добавляя новый вариант результата операции, мы получаем возможность обнаружить обработчики, которые необходимо обновить. Но есть нюанс: если добавить универсальную ветку _, компилятор будет считать, что остальные варианты тоже обработаны. Поэтому для union types часто полезнее перечислять конкретные варианты явно.
6. Union не заменяет проверку JSON#
Вернёмся к нашему API.
Может возникнуть логичный вопрос: а если System.Text.Json поддерживает union types, почему бы просто не десериализовать внешний JSON непосредственно в Description? В некоторых сценариях это возможно, но в нашем случае так не получится. Внешний API не предоставляет явного признака варианта объединения, а тип данных определяется структурой значения. Именно поэтому в проекте принято решение разделить транспортную и доменную модели.
Транспортная модель#
Для чтения ответа используется класс WorkPayload:
public sealed class WorkPayload
{
[JsonPropertyName("title")]
public required string Title { get; init; }
[JsonPropertyName("description")]
[JsonIgnore(
Condition = JsonIgnoreCondition.WhenWritingDefault)]
public JsonElement Description { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>?
AdditionalProperties { get; init; }
}
Здесь JsonElement сохраняет исходную структуру description. Мы можем определить, является ли значение строкой, объектом, null или отсутствующим свойством. JsonExtensionData позволяет сохранить остальные поля ответа, даже если наша учебная модель их явно не описывает.
Преобразование в доменный union#
После десериализации выполняется преобразование:
public static Description FromJson(
JsonElement value) => value.ValueKind switch
{
JsonValueKind.Undefined or JsonValueKind.Null =>
new NoDescription(),
JsonValueKind.String =>
new PlainText(value.GetString()!),
JsonValueKind.Object =>
ReadObject(value),
_ => throw new JsonException(
"description must be a string, text object, null or absent.")
};
Метод ReadObject проверяет обязательные поля и возвращает TextObject, если структура соответствует ожидаемому формату.
В результате мы получаем два уровня ответственности:
JsonElementи транспортная модель сохраняют структуру внешнего ответа.Descriptionпредставляет проверенные варианты данных, с которыми работает наше приложение.
И это, на мой взгляд, гораздо более правильный подход, чем пытаться запихнуть всю логику в один громоздкий JsonConverter. Особенно когда внешний API нам не принадлежит и его структура может меняться независимо от нашего приложения.
7. Missing, null и default: важные различия#
Одна из тонкостей работы с внешним JSON заключается в различии между отсутствующим свойством и явным null.
Например:
{
"title": "Book A"
}
И:
{
"title": "Book B",
"description": null
}
Это разные JSON-документы. В транспортной модели JsonElement позволяет сохранить различие: в первом случае ValueKind будет Undefined, во втором — Null. Но в нашем случае оба варианта означают одно и то же: описания книги нет. Поэтому метод FromJson преобразует оба состояния в NoDescription. Это осознанное решение. Если бы мы разрабатывали API обновления данных, где отсутствие свойства означает «не изменять», а null — «очистить», объединять эти состояния было бы неправильно.
А что происходит с default?#
Здесь нас ждёт менее очевидная особенность. Объявление union в C# 15 генерирует значимый тип — struct. Следовательно, у него есть значение по умолчанию.
Например:
Description description = default;
Но какой из вариантов оно содержит? Никакой!
Внутреннее значение объединения оказывается null. Именно поэтому в нашем методе Render есть отдельная ветка:
null => throw new InvalidOperationException(
"Uninitialized Description union.")
И это важное ограничение новой конструкции.
Union typesпомогают исключить некоторые недопустимые состояния, но не гарантируют, что любое значение объединения автоматически корректно инициализировано. Поэтому при работе с ними необходимо учитыватьdefault, особенно если объединение используется в массивах, полях структур или других местах, где значения могут создаваться без явной инициализации.
8. Второй пример: моделируем результат HTTP-запроса#
До сих пор мы использовали union для представления неоднородного поля description. Но это далеко не единственный сценарий применения. Представим метод, который обращается к внешнему API. Что он может вернуть? Очевидно, успешный результат. Но возможны и другие ситуации: книга не найдена, сервер ответил ошибкой, соединение оборвалось, произошёл тайм-аут или получен некорректный JSON. В привычном подходе можно было бы возвращать null, использовать исключения или создавать собственную обёртку с полями Success, Data и Error. Однако здесь снова возникает проблема: некоторые комбинации полей не имеют смысла.
Моделируем возможные результаты#
В нашем проекте объявлены следующие типы:
public sealed record WorkFound(WorkPayload Work);
public sealed record WorkNotFound(string WorkId);
public sealed record WorkRedirect(string Location);
public sealed record HttpFailure(HttpStatusCode Status);
public sealed record NetworkFailure(string Message);
public sealed record InvalidPayload(string Message);
public sealed record RequestTimedOut;
И объединение:
public union FetchResult(
WorkFound,
WorkNotFound,
WorkRedirect,
HttpFailure,
NetworkFailure,
InvalidPayload,
RequestTimedOut);
Теперь метод получения книги возвращает:
public async Task<FetchResult> FetchAsync(
string workId,
CancellationToken cancellationToken = default)
Обратите внимание: тип результата явно сообщает вызывающему коду обо всех предусмотренных исходах операции. При этом не требуется создавать общий базовый класс для ошибок или объединять их в один объект с набором nullable-свойств.
Обработка результата#
Вызывающий код использует switch:
return result switch
{
WorkFound found =>
Show(found.Work, mode, output),
WorkNotFound missing =>
Fail(error, $"Not found: {missing.WorkId}"),
WorkRedirect redirect =>
ShowRedirect(output, redirect.Location),
HttpFailure failure =>
Fail(error, $"HTTP: {(int)failure.Status}"),
NetworkFailure failure =>
Fail(error, $"Network: {failure.Message}"),
InvalidPayload failure =>
Fail(error, $"Invalid JSON: {failure.Message}"),
RequestTimedOut =>
Fail(error, "Request timed out."),
null => throw new InvalidOperationException(
"Uninitialized FetchResult.")
};
Получается довольно выразительный код. Каждый вариант результата обрабатывается отдельно, а при добавлении нового варианта компилятор поможет обнаружить неполный switch. Это уже полноценный пример применения union types в архитектуре приложения, а не просто демонстрация нового синтаксиса.
9. Неожиданная особенность Open Library: HTTP 200 ещё не означает, что мы получили книгу#
Во время разработки проекта обнаружилась интересная ситуация.
Запрос:
GET https://openlibrary.org/works/OL45883W.json
возвращал успешный HTTP-статус 200 OK, но вместо обычных сведений о произведении сервер передавал JSON-перенаправление.
Упрощённо оно выглядит так:
{
"type": {
"key": "/type/redirect"
},
"location": "/works/OL45804W"
}
Это не обычное HTTP-перенаправление с кодом 301 или 302, которое может автоматически обработать HttpClient. Здесь HTTP-запрос завершился успешно, но тело ответа сообщает, что нужные данные находятся по другому идентификатору. Если проверять только response.IsSuccessStatusCode, можно ошибочно принять такой ответ за полноценную запись книги.
В нашем клиенте этот случай распознаётся отдельно и преобразуется в:
new WorkRedirect(location)
Таким образом, вызывающий код получает явно типизированный результат и может самостоятельно решить, что делать дальше: повторить запрос, показать пользователю новый идентификатор или обработать перенаправление другим способом. В демонстрационном приложении автоматический переход по JSON-перенаправлению намеренно не выполняется. Вместо этого программа выводит целевой идентификатор, чтобы поведение было очевидным. Этот пример хорошо показывает ещё одно преимущество union types: они позволяют явно описывать не только ошибки, но и другие предусмотренные состояния операции, которые нельзя считать обычным успешным результатом.
10. Что происходит внутри union#
До сих пор мы рассматривали объединения с точки зрения использования. Но полезно понять и то, как они устроены. В C# 15 нативное объявление union генерирует структуру, которая хранит текущее значение через object?. Это означает, что при использовании ссылочных типов дополнительной упаковки самого значения не требуется. А вот для значимых типов возникает boxing.
В проекте есть небольшой пример:
public union NumberOrText(int, string);
Создадим значение:
NumberOrText value = 42;
А затем посмотрим на его внутреннее представление:
Console.WriteLine(
value.Value?.GetType().FullName);
Результат:
System.Int32
Здесь число 42 хранится как упакованное значение int. Следовательно, union types не следует воспринимать как автоматически более производительную замену любой существующей модели данных. Для большинства прикладных сценариев, где важнее выразительность модели и безопасность обработки вариантов, это может быть вполне приемлемым компромиссом. Но для высоконагруженных участков кода, особенно с большим количеством значимых типов, стоит учитывать возможные аллокации и проводить измерения. В демонстрационном проекте поведение boxing проверено, однако полноценные benchmarks не проводились.
11. Запускаем проект самостоятельно#
Чтобы не ограничиваться теорией, я подготовил небольшой учебный проект CSharp15.UnionTypes.Demo. Он содержит две реализации обработки описаний книг: старую модель с nullable-свойствами и новую модель на основе нативного union. Кроме того, приложение демонстрирует работу с реальным Open Library API, обработку HTTP-результатов и особенности самого языка.
Требования#
Для запуска нужен релизный .NET SDK 11.0.100 или совместимая версия. Проект использует C# 15, а предупреждения компилятора считаются ошибками.
Клонируем репозиторий:
git clone https://github.com/algmironov/CSharp15.UnionTypes.Demo.git
cd CSharp15.UnionTypes.Demo
Восстанавливаем зависимости и собираем решение:
dotnet restore CSharp15.UnionTypes.Demo.slnx
dotnet build CSharp15.UnionTypes.Demo.slnx -c Release --no-restore
Сравнение двух подходов#
Запускаем режим compare:
dotnet run --project src/UnionTypes.Demo -c Release --no-build -- compare
Приложение использует четыре локальных тестовых ответа: строку, объект, отсутствующее поле и явный null.
Пример вывода:
Synthetic string [String]
legacy: text: A sample description.
union: text: A sample description.
Synthetic object [Object]
legacy: object: A sample description.
union: object: A sample description.
Synthetic missing [Undefined]
legacy: none
union: none
Synthetic null [Null]
legacy: none
union: none
В итоге обе реализации дают одинаковый результат. Так и должно быть. Цель union types не в том, чтобы изменить смысл данных, а в том, чтобы сделать модель более строгой и удобной для дальнейшего развития.
Получение данных из реального API#
Для обращения к Open Library используется параметр --live:
dotnet run --project src/UnionTypes.Demo -c Release --no-build -- union --live OL45804W
Этот идентификатор использовался при проверке ответа со строковым описанием.
Для проверки объектной формы можно выполнить:
dotnet run --project src/UnionTypes.Demo -c Release --no-build -- union --live OL82563W
А если запросить OL45883W, приложение покажет JSON-перенаправление на другой идентификатор.
Тесты#
Проект содержит автоматические тесты, покрывающие разбор JSON, допустимые и недопустимые варианты описания, обработку HTTP-статусов, перенаправления, сетевые ошибки и особенности union.
Запуск:
dotnet test CSharp15.UnionTypes.Demo.slnx -c Release --no-build
На момент подготовки статьи проект был проверен на Windows 11 (x64) с релизным .NET SDK 11.0.100.
Результаты: 50 успешно пройденных тестов, 0 ошибок и 0 предупреждений компиляции.
Отдельно проверен сценарий с добавлением нового варианта union и неполным switch. Компилятор действительно выдаёт предупреждение CS8509, а после добавления недостающей ветки проект снова собирается без предупреждений.
12. Когда стоит использовать union types#
После всех примеров может возникнуть желание заменить объединениями типов чуть ли не все результаты методов. Но, как и с любой новой возможностью языка, здесь стоит сохранять разумный баланс.
Вот несколько ситуаций, в которых union types особенно полезны.
Результаты операций. Когда метод может завершиться несколькими предусмотренными способами и вызывающий код должен различать их. Например, успешное создание заказа, отказ из-за недостатка средств или обнаружение конфликта данных.
Интеграции с внешними API. Когда один элемент данных может иметь несколько допустимых форм или API возвращает разные виды ответов. При этом разбор и проверку входящих данных по-прежнему следует выполнять на границе приложения.
Моделирование состояний. Когда объект может находиться в одном из нескольких взаимоисключающих состояний. Например, задача ожидает выполнения, выполняется, завершена или отменена.
Публичные контракты библиотек. Когда важно явно сообщить потребителю, какие результаты он должен обработать, и получать диагностику компилятора при расширении набора вариантов.
Но есть и случаи, когда union не нужен. Если метод возвращает обычный объект или простое значение, добавление объединения только усложнит код. Так же не стоит забывать и о том, что union не заменяет исключения для действительно непредвиденных ошибок, не валидирует содержимое вариантов и не гарантирует корректную инициализацию значения по умолчанию.
Заключение#
Появление нативных union types в C# 15 — не просто очередное синтаксическое улучшение. Это новый инструмент моделирования данных, позволяющий точнее выразить допустимые состояния и перенести часть проверок на этап компиляции. В нашем примере мы начали с проблемы неоднородного JSON, которую раньше решали с помощью nullable-свойств и ручных проверок. Затем заменили эту модель объединением типов и получили более строгий контракт, не допускающий одновременного хранения нескольких вариантов.
После этого оказалось, что тот же механизм отлично подходит для описания результатов HTTP-запросов, включая успешные ответы, ошибки и даже необычные JSON-перенаправления. При этом у новой конструкции есть ограничения: default может содержать null, значимые типы подвергаются boxing, а проверка внешних данных остаётся ответственностью приложения.
Лично мне больше всего нравится возможность полноценной обработки вариантов через switch. Когда добавление нового состояния заставляет компилятор указать на забытые обработчики, это действительно помогает сопровождать и развивать код.
Именно ради таких возможностей и стоит изучать новые версии языка.
Исходный код проекта: CSharp15.UnionTypes.Demo на GitHub