Skip to content

Repository files navigation

Platonic.CSharp

Platonic C# is a restricted subset of the C# language in which data cannot change after it is created, and functions cannot reach outside themselves for input. The subset is defined by twelve Roslyn analyzers that run as part of every build and report violations as compiler errors, not warnings.

It is intended for developers who write C# with the help of coding agents (Claude Code, Copilot, Cursor, and similar tools), and for developers who want the functional style they already follow by hand to be checked by the compiler instead of by code review.

The problem it solves

When a large language model writes C#, its most common class of mistake is not syntax and not algorithmic reasoning. It is state: an object mutated in one method and read in another, two variables that turn out to alias the same list, an initialization that has to happen before some other call, a field written by a helper three layers down. These bugs do not appear in the function the agent is looking at. They appear in the interaction between that function and code that is not in the agent's context window.

The usual mitigation is to write the rules in a prompt or a CLAUDE.md file: prefer immutable types, avoid mutable statics, do not use DateTime.Now. This works for a while and then stops working. Instructions in a prompt compete with everything else in the context window, they are applied inconsistently across a long session, and the model was trained on a corpus of mutable C# that pulls in the opposite direction. Nothing tells you when a rule has been forgotten.

An analyzer that reports error PURE005 does not have this failure mode. The build fails, the agent sees the exact file, line, and reason, and it corrects the code before you ever look at it. The rule is enforced by a program rather than remembered by a model. That difference — machine-checked rather than prompt-checked — is the point of this project.

What it looks like

A file that violates the rules produces this at build time:

Geometry.cs(49,14): error PURE001: Type 'Leak' is not a permitted pure type: plain mutable class;
use a record, a readonly record struct, a static class, or mark it [TrustedMutableKernel]
Geometry.cs(49,41): error PURE002: Property 'X' declares a set accessor; use get/init and with-expressions instead
Geometry.cs(49,92): error PURE003: Field 'Items' is not readonly or const; mutable state (including static state) is not allowed
Geometry.cs(49,14): error PURE004: 'Leak' breaks sealed-by-default: non-abstract type is not sealed
Geometry.cs(49,129): error PURE005: Member 'X' is mutated after construction; build a new value instead
Geometry.cs(49,55): error PURE007: 'Items' exposes mutable collection type 'System.Collections.Generic.List<int>';
use ImmutableArray<T>, IReadOnlyList<T> or IEnumerable<T>
Geometry.cs(49,133): error PURE010: 'Console.WriteLine' is ambient impurity; inject clock/rng/IO as values or interfaces instead

Each message names the violation and the accepted alternative. That second half matters more than it looks: an agent reading error PURE007 is told what to write instead, so the correction loop usually converges in a single iteration.

Code that satisfies the rules looks like ordinary modern C#:

publicreadonlyrecordstructPoint(doubleX,doubleY);publicsealedrecordPolyline(ImmutableArray<Point>Points){publicPolylineTranslate(Pointdelta)=>new(Points.Map(p =>p+delta));publicdoubleLength(){vartotal=0.0;// local mutation is allowedfor(vari=1;i<Points.Length;i++)total+=Distance(Points[i-1],Points[i]);returntotal;}}

The rules

IdRule
PURE001No plain mutable classes. Permitted types are record, readonly record struct, enum, interface, static class, attribute classes, and exception classes.
PURE002Properties declare get and init only. No set accessors.
PURE003Every field is readonly or const. This also forbids mutable static state.
PURE004Sealed by default. No virtual members and no implementation inheritance. Overrides are permitted only for abstract members (so that closed unions work) and for ToString, Equals, and GetHashCode.
PURE005No member state is assigned after construction: no assignment, compound assignment, or ++/-- targeting a field, property, or indexer. Constructors and init accessors are exempt.
PURE006No event declarations.
PURE007Publicly visible signatures do not use mutable collection types such as List<T> or Dictionary<K,V>. Use ImmutableArray<T>, IReadOnlyList<T>, or IEnumerable<T>.
PURE008No unsafe code, pointer types, dynamic, or finalizers.
PURE009No reflection over your own types, and no System.Activator. typeof(T) and Type.Name remain available.
PURE010No ambient impurity: DateTime.Now, unseeded new Random(), Guid.NewGuid, Console, Environment, File, Directory, and similar. Pass a clock, a seeded random source, or a file interface as an argument.
PURE011Exceptions signal bugs, not domain outcomes. Only ArgumentException and its relatives, InvalidOperationException, NotSupportedException, NotImplementedException, and UnreachableException may be thrown. Expected failures return Result<T, TError>.
PURE012Fields and automatically implemented properties do not hold mutable collections, even privately.

The diagnostic prefix is PURE rather than PLAT because the identifiers appear in build output many times a day and are easier to read that way.

The escape hatches

A purity rule set with no way out is unusable for real work, particularly for the geometry and mesh processing this style was developed for. There are three ways to opt out, in increasing order of scope.

Local mutation is always allowed. PURE005 constrains member state, not local variables. A function may allocate a builder or a stack array, fill it in a tight indexed loop, freeze it, and return the frozen result. The function is observationally pure — its output depends only on its arguments and nothing it mutated is visible to the caller — while running at the speed of ordinary imperative code. Without this, persistent collections would make geometry code two to ten times slower and the whole subset would be impractical. This is the same distinction that Koka and D draw between an internally imperative function and an externally pure one.

[TrustedMutableKernel] marks a type as an audited mutable kernel and disables the mutation rules for it. Use it for a spatial index, a vertex welder, or a mesh builder whose internals must be mutable, and keep the type small enough to review by hand.

[Impure] disables the mutation and effect rules for whatever it is applied to. It marks the imperative shell: the code that reads files, writes to the console, and calls the clock. Applied to an assembly it disables the rules for that whole assembly, which is almost never what you want.

The intended structure is a functional core with no input or output at all, and a thin shell that performs the effects and calls into the core.

Getting started

You need the .NET 10 SDK. The project was built and tested with 10.0.301 on Windows 11 on 2026-08-17. Nothing in it is platform-specific, but it has not been run on Linux or macOS.

One package reference applies the whole rule set:

dotnet add package Platonic.Analyzers

That package contains the analyzers and depends on Platonic.Core, which supplies Result<T, TError>, Option<T>, and the two attributes. It also brings a properties file that turns on nullable reference types and treats warnings as errors, since the rules assume both. If your project already sets either property, your value is kept.

Package status as of 2026-08-17: both packages build and have been verified by installing them into a separate project from a local folder feed, but they have not been pushed to nuget.org yet, so the command above does not resolve. Until then, either build the packages yourself and add the artifacts folder as a package source:

dotnet pack src/Platonic.Core -c Release
dotnet pack src/Platonic.Analyzers -c Release

or reference the projects directly, where OutputItemType="Analyzer" is what loads the analyzers into the compiler:

<ItemGroup>
<ProjectReferenceInclude="path/to/src/Platonic.Core/Platonic.Core.csproj" />
<ProjectReferenceInclude="path/to/src/Platonic.Analyzers/Platonic.Analyzers.csproj"OutputItemType="Analyzer"ReferenceOutputAssembly="false" />
</ItemGroup>

To confirm the rules are active, add a mutable class to your project and rebuild. The build should fail with error PURE001. If it succeeds, the analyzers are not loaded. You can also check this repository itself:

dotnet build && dotnet test tests/Platonic.Analyzers.Tests

You should see 125 passing tests.

What you get from it

Reasoning stays local. The meaning of a function is its signature plus its body. Because nothing it calls can modify state it can see, an agent (or a person) reading one function does not need the rest of the mutation graph in context. This is the largest single benefit and the reason the rest of the list follows.

Refactoring preserves behavior by construction. Extracting a method, inlining one, reordering two independent calls, or running a loop in parallel are all safe when the calls involved are pure. Agents can restructure code aggressively, and the reviewer has less to check.

Tests reduce to inputs and outputs. There is no setup order to get right, no mocks, and no fixtures. Records give structural equality, so asserting on a whole result value is one line. Property-based tests and golden tests become the natural style.

Failures reproduce. With no ambient clock, no unseeded randomness, and no hidden global state, a failing run fails the same way the next time. An agent's debugging loop converges instead of chasing a symptom that moves.

Parallel agents collide less. Immutable data does not produce the god objects that every change has to touch, so several agents working on one repository tend to edit disjoint files.

Errors are visible in signatures. A function that returns Result<Point, ParseError> states that it can fail and how. Nothing is thrown past a caller that did not ask about it.

Trade-offs

Persistent collections cost performance. Rebuilding an immutable structure instead of mutating one is roughly two to ten times slower for collection-heavy work. The local-mutation escape hatch is the mitigation, and for the largest workloads you will still want [TrustedMutableKernel] types.

The base class library resists.Stream, List<T>, and most of the framework are mutable, so there is real conversion work at every boundary between your code and the platform.

Models trained on mutable C# violate these rules constantly at first. This is expected. It is also exactly why the rules are compiler errors: a warning would be ignored, an error cannot be.

The subset is close to what F# gives you by default. The argument for a C# subset instead is practical rather than theoretical: coding agents have seen far more C# than F#, the Roslyn tooling is more mature, and existing C# code can be migrated incrementally rather than rewritten.

Structural equality on large values is a trap. Record equality is a deep comparison, so comparing two large meshes with == is an O(n) operation that looks like an O(1) one. No analyzer catches this yet; see the next-steps section.

What is tested and what is not

Verified on 2026-08-17: the twelve analyzers are covered by 125 passing unit tests using the Roslyn analyzer testing framework; the sample project in samples/Platonic.Sample compiles cleanly under all twelve rules; and a deliberately non-conforming class added to that sample produced nine errors across seven rules and failed the build. The [TrustedMutableKernel] escape hatch was checked in both directions: the mutable accumulator in samples/Platonic.Sample/Kernel.cs compiles with the attribute and fails with six errors across three rules without it. The two packages were installed into a project outside this repository from a local folder feed; conforming code built cleanly, a non-conforming file produced nine errors across seven rules, and the bundled properties file set nullable reference types and warnings-as-errors in a project that specified neither.

Not yet demonstrated: no substantial existing codebase has been ported to the subset, so the ergonomics of [TrustedMutableKernel] under real load are unknown. No performance measurements have been taken. The central claim — that agents produce better C# faster under machine-checked rules than under prompt-checked ones — is plausible from the mechanism but has not been measured. There is no continuous integration and no published package. The project is one day old.

Known gaps recorded in NOTES.md include Random.Shared and the Task family passing PURE010, System.Collections.Concurrent missing from the banned collection list, and tuple deconstruction into members escaping PURE005.

Prior and related work

PurelySharp and Yacoub Massad's PurityAnalyzer check the purity of individual methods that you annotate. Apex.Analyzers.Immutable and SmartAnalyzers.CSharpExtensions.Annotations enforce immutability on types that you mark. All of them are opt-in: you annotate the code you want checked, and everything else is unconstrained.

Platonic C# inverts that default. The whole assembly is constrained, and the attributes opt code out. This matters for agent workflows specifically, because an opt-in rule only constrains code that someone remembered to annotate — which is the same failure mode as writing the rules in a prompt. Whether inverting the default is the better choice for human-only teams is genuinely unclear.

The subset is roughly the semantics of Plato expressed in C#. F# is this by default and has been for twenty years.

How the repository is organized

PathContents
src/Platonic.CoreResult<T, TError>, Option<T>, Unit, and the [Impure] and [TrustedMutableKernel] attributes.
src/Platonic.AnalyzersThe twelve analyzers, grouped as Types, Mutation, Boundaries, and Effects, over shared helpers in Common.
tests/Platonic.Analyzers.Tests125 tests, one class per analyzer.
samples/Platonic.SampleA small geometry and parsing domain that compiles under all rules.
PLAN.mdThe build plan and the deferred rule list.
NOTES.mdImplementation findings and known gaps, one section per rule group.
CONTRACTS.mdThe file-ownership fences used when several agents worked on this repository at once.

Next steps

These are ordered by how much each one increases the value of the system.

1. Push the packages to nuget.org. They are built, laid out correctly, and verified against a local feed; only the upload remains. This is the step that makes the project usable by anyone who is not willing to clone it.

2. Write code fix providers. An analyzer tells an agent what is wrong; a code fix tells the compiler how to repair it. Mechanical rules are the candidates: set becomes init, a missing sealed is added, a List<T> parameter becomes IReadOnlyList<T>. Every fix that can be applied automatically is an agent iteration that never has to happen.

3. Port a real, demanding codebase. A geometry or mesh-processing library is the honest test, because it is where immutability is most expensive. This is the step that will reveal whether [TrustedMutableKernel] is drawn in the right place, how large the boundary conversion tax actually is, and which rules are too strict to live with.

4. Measure the agent loop. Give an agent the same task with the analyzers on and off, and count the iterations to a correct result, the defects that survive review, and the tokens consumed. The project's central claim deserves a number rather than an argument.

5. Close the known gaps. Add Random.Shared, Task.Run, Task.Delay, and the concurrent collections to the banned lists; decide what to do about throw ex; inside a catch block; handle tuple deconstruction in PURE005. These are recorded with context in NOTES.md.

6. Add the large-value equality rule. Deep structural equality on records that hold large collections is the most likely performance surprise in this style, and it is exactly the kind of thing a syntax-level analyzer can catch.

7. Set up continuous integration. A guard rail that is not run on every pull request is not a guard rail.

8. Write the agent-facing rule document. A short CLAUDE.md or AGENTS.md that states the subset and, more usefully, tells an agent what to do when it sees each PUREnnn error. The analyzers make the rules enforceable; a document like this makes the first attempt more likely to be correct.

History

An earlier project of the same name, developed from 2023, defined Platonic C# as a set of written style guidelines for a subset of C# 7.3. Its central idea was uniqueness typing: allowing mutable types provided that at most one reference to a mutable value exists at any time, which preserves referential transparency even in the presence of mutation. Types were annotated [Mutable] or [Immutable], and methods [Pure], [ImpureWrite], or [ImpureEffect]. That repository shipped a console tool that listed the rules; the Roslyn analyzer it described was never written. The project was archived when the effort moved to Plato, a separate pure functional language that uses a subset of C# syntax.

This version keeps the goal and changes nearly every decision beneath it. Records and readonly record struct did not exist in C# 7.3, so the earlier rules had to ban structs and rely on classes with private setters; they are now the preferred types. Annotation is inverted: the earlier design required an attribute on every type and method to say what it was, whereas here the constrained form is the default and attributes mark the exceptions. Uniqueness typing is replaced by the simpler rule that mutation is fine as long as nothing mutated escapes the function that created it, which addresses the same performance problem with far less machinery. And the analyzer that the earlier repository listed as future work is what this repository actually is.

The old code remains in this repository's git history before the commit that removed it.

Status and license

Experimental, first working version completed 2026-08-17. The rule set and the diagnostic identifiers may still change. Licensed under the MIT License; see LICENSE.

About

Safe cross-platform functional programming directly in C#.

Resources

Stars

25 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages