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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- csharp-modern Installs to ~/.agents/skills/csharp-modern/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install csharp-modern The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project csharp-modern Installs to .agents/skills/csharp-modern/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/csharp-modern.zipunzip -o csharp-modern.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) csharp-modern PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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 framework | Default C# | Notable features available |
|---|---|---|
| net8.0 (LTS) | 12 | primary constructors, collection expressions, alias any type, default lambda params |
| net9.0 | 13 | params collections, System.Threading.Lock, \e, partial properties, ^ in object initializers |
| net10.0 (LTS) | 14 | field 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
| Situation | Fix |
|---|---|
| Parameter checked at entry | ArgumentNullException.ThrowIfNull(order); |
| String argument must be non-empty | ArgumentException.ThrowIfNullOrWhiteSpace(name); |
TryGet style method | bool 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 JSON | required 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);
}
}
| Type | Use |
|---|---|
record (class) | Immutable messages, events, DTOs with value equality |
readonly record struct | Small value types (ids, coordinates) without allocation |
Class with required init properties | DTOs with many optional members, JSON contracts |
| Class with primary constructor | Services receiving dependencies via DI |
| Plain class | Entities 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. UseImmutableArray<T>with custom equality, or compare explicitly. - Record
ToString()prints every property, including secrets and large payloads, into logs. OverridePrintMembersor keep sensitive data out of records. withexpressions copy shallowly; mutable reference members are shared.- Collection expressions for
IEnumerable<T>targets produce a compiler-chosen type; do not cast it back toList<T>. requireddoes 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 voidoutside event handlers crashes the process on exceptions; returnTask.DateTime.Nowin business logic is untestable; injectTimeProvider(.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.yaml
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.