Modern C# skill for Codex

Nullable on, boilerplate off, analyzers enforcing it. Codex checks the target framework before using any feature, then turns on nullable reference types project by project, fixing warnings with boundary checks and flow attributes instead of the null-forgiving operator. DTOs become records or required init classes, services get primary constructors, and Directory.Build.props plus analyzers make the new style the enforced default for .NET 8 through .NET 10.

  • Languages
  • $csharp-modern
  • 200 lines
  • 2 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- csharp-modern

Installs to ~/.agents/skills/csharp-modern/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $csharp-modern, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Modernizes C# code for .NET 8 to .NET 10 - nullable reference types enabled and enforced, records and required members, primary constructors, collection expressions, pattern matching, raw string literals, C# 13 params collections and System.Threading.Lock, and C# 14 field-backed properties, extension members and null-conditional assignment - with analyzers and Directory.Build.props settings to keep it that way. Use when enabling nullable in an existing solution, upgrading LangVersion or target framework, replacing boilerplate DTOs, or reviewing C# for current idioms.

Use it when

  • A .NET solution still has nullable disabled or thousands of nullable warnings
  • You are upgrading the target framework or LangVersion and want to use new features
  • DTOs, value objects and services are full of constructor and property boilerplate
  • You want analyzers and editorconfig rules that keep C# style consistent

Use something else when

  • You are building ASP.NET Core endpoints, middleware or hosting: use aspnet-core
  • The work is Unity game scripting in C#: use unity-csharp

How to check the result

dotnet build -warnaserror and dotnet test pass, dotnet format --verify-no-changes is clean, and every remaining null-forgiving operator in the diff has a comment.

The mistake it prevents

Silencing nullable warnings with ! and default!, which keeps the NullReferenceExceptions and removes the compiler's ability to point at them.

~/.agents/skills/csharp-modern/SKILL.md

--- name: csharp-modern description: Modernizes C# code for .NET 8 to .NET 10 - nullable reference types enabled and enforced, records and required members, primary constructors,… license: MIT ---

Done well means: nullable reference types are on for every project with warnings treated as errors, each new language feature is used only where the project’s C# version supports it and it reads better than the old form, and analyzers enforce the result so it does not regress.

Establish versions first

dotnet --info | head -20
grep -rn 'TargetFramework\|LangVersion\|Nullable\|TreatWarningsAsErrors\|AnalysisLevel' --include='*.csproj' --include='Directory.Build.props' .
cat global.json 2>/dev/null
Target frameworkDefault C#Notable features available
net8.0 (LTS)12primary constructors, collection expressions, alias any type, default lambda params
net9.013params collections, System.Threading.Lock, \e, partial properties, ^ in object initializers
net10.0 (LTS)14field keyword, extension members, null-conditional assignment, nameof on unbound generics

Do not raise LangVersion above the framework default to get features; some depend on runtime types. Changing the target framework is a deployment decision: confirm with the user. Read AGENTS.md for build and test commands. dotnet restore needs network unless packages are already in the local cache.

Solution-wide settings

<!-- Directory.Build.props at the repo root -->
<Project>
  <PropertyGroup>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <AnalysisLevel>latest-recommended</AnalysisLevel>
    <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
  </PropertyGroup>
</Project>

Combine with .editorconfig severities for style rules (IDE0290 use primary constructor, IDE0300-IDE0305 collection expressions, IDE0090 target-typed new). If turning on TreatWarningsAsErrors for an existing codebase is too large, start with <WarningsAsErrors>nullable</WarningsAsErrors>.

Enabling nullable in an existing codebase

- [ ] 1. Enable per project, leaf projects (no dependents) first, or per file with #nullable enable
- [ ] 2. Annotate public API signatures first: what can be null is T?, everything else T
- [ ] 3. Fix warnings by validating at boundaries (ArgumentNullException.ThrowIfNull), not with !
- [ ] 4. Add flow attributes where the compiler cannot infer (table below)
- [ ] 5. Make warnings errors for that project; move to the next one
SituationFix
Parameter checked at entryArgumentNullException.ThrowIfNull(order);
String argument must be non-emptyArgumentException.ThrowIfNullOrWhiteSpace(name);
TryGet style methodbool TryGet(string key, [NotNullWhen(true)] out Item? item)
Helper initialises fields[MemberNotNull(nameof(_conn))] void Open()
Returns null only if input is null[return: NotNullIfNotNull(nameof(input))] string? Normalize(string? input)
Property set by framework (EF Core, options binding)required member, or = null!; with a comment for EF navigation properties
DTO from JSONrequired properties; System.Text.Json honours required since .NET 8

The null-forgiving ! is a claim the compiler cannot check. Allow it only with a comment, and never as default! on values that really can be missing.

Records, required members and primary constructors

public sealed record OrderPlaced(Guid OrderId, string CustomerId, decimal Total);

public sealed class CreateOrderRequest
{
    public required string CustomerId { get; init; }
    public required IReadOnlyList<LineDto> Lines { get; init; }
    public string? CouponCode { get; init; }
}

public sealed class OrderService(IOrderRepository repo, TimeProvider clock, ILogger<OrderService> log)
{
    public async Task<OrderPlaced> PlaceAsync(CreateOrderRequest req, CancellationToken ct)
    {
        var order = Order.Create(req.CustomerId, req.Lines, clock.GetUtcNow());
        await repo.AddAsync(order, ct);
        log.LogInformation("Order {OrderId} placed", order.Id);
        return new OrderPlaced(order.Id, order.CustomerId, order.Total);
    }
}
TypeUse
record (class)Immutable messages, events, DTOs with value equality
readonly record structSmall value types (ids, coordinates) without allocation
Class with required init propertiesDTOs with many optional members, JSON contracts
Class with primary constructorServices receiving dependencies via DI
Plain classEntities with identity and behaviour (EF Core entities)

Primary constructor parameters are captured as mutable hidden fields, not properties. For services that is fine; for values that need validation or must be readonly, assign to a private readonly field or use a record.

Expressions and syntax worth adopting

int[] ids = [1, 2, 3];
List<string> names = [];
string[] all = [..defaults, ..overrides];                     // collection expressions (12)

var label = shape switch                                      // patterns
{
    Circle { Radius: > 100 } => "large circle",
    Circle c => $"circle r={c.Radius}",
    Rect(var w, var h) when w == h => "square",
    null => "none",
    _ => "other",
};

var range = ids switch { [] => "empty", [var only] => $"{only}", [var first, .., var last] => $"{first}..{last}" };

var sql = """
    SELECT id, total
    FROM orders
    WHERE customer_id = @customerId
    """;                                                      // raw string literal (11)

private readonly Lock _gate = new();                          // C# 13 / .NET 9
lock (_gate) { _count++; }                                    // uses Lock.EnterScope

public void Log(params ReadOnlySpan<string> parts) { }        // params collections (13)

C# 14 (.NET 10):

public string Name
{
    get;
    set => field = value?.Trim() ?? throw new ArgumentNullException(nameof(value));
}                                                              // field keyword

customer?.LastSeen = clock.GetUtcNow();                        // null-conditional assignment

public static class EnumerableExtensions
{
    extension<T>(IEnumerable<T> source)                        // extension members
    {
        public bool IsEmpty => !source.Any();
    }
}

If a type already has a member called field, the C# 14 keyword changes its meaning inside accessors; use @field or this.field for the member.

Gotchas

  • Record equality on collections is reference equality: two records holding equal List<T> instances are not equal. Use ImmutableArray<T> with custom equality, or compare explicitly.
  • Record ToString() prints every property, including secrets and large payloads, into logs. Override PrintMembers or keep sensitive data out of records.
  • with expressions copy shallowly; mutable reference members are shared.
  • Collection expressions for IEnumerable<T> targets produce a compiler-chosen type; do not cast it back to List<T>.
  • required does not run validation; it only forces initialisation at compile time. Deserialisers and reflection can still create invalid objects. Validate in constructors or with DataAnnotations/FluentValidation at the boundary.
  • async void outside event handlers crashes the process on exceptions; return Task.
  • DateTime.Now in business logic is untestable; inject TimeProvider (.NET 8+).

Verification

dotnet build -warnaserror
dotnet test
dotnet format --verify-no-changes          # style and analyzer rules from .editorconfig

Count ! null-forgiving operators and #nullable disable directives in the diff; each one needs a reason. Run a JSON round-trip test for DTOs that gained required members.

Report template

**Frameworks:** net8.0 -> net10.0 (C# 14), approved by you
**Nullable:** enabled in 7/7 projects, warnings as errors; 3 ! with comments remain
**Refactors:** 22 DTOs -> records / required init; 9 services -> primary constructors
**Analyzers:** AnalysisLevel latest-recommended, EnforceCodeStyleInBuild on
**Checks:** dotnet build -warnaserror clean; dotnet test 1,812 passed; dotnet format clean

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Modern C#"
  short_description: "Nullable on, boilerplate off, analyzers enforcing it"
  default_prompt: "$csharp-modern Enable nullable reference types across this solution and fix the warnings properly"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc