8Examples / blog
Languages · Testing · Agentic coding

Every Language Feature, Version by Version

Three repositories that file every C#, JavaScript, and TypeScript feature under the version that introduced it, each one proven by a test that actually runs. Here is how they are built, a few of the details worth stealing, and what it means that each repo came out of a single unattended terminal session.

By Sean Bennett · August 23, 2026 · 11 min read

Language changelogs are written for people who already know the language. “Primary constructors” is a heading, not an explanation. The release notes tell you the syntax exists; they rarely tell you what it replaced, what it does at the edges, or whether your runtime actually implements it yet.

So I built the version of that document I wanted: three repositories where every feature the language ever shipped lives in a file of its own, filed under the release that introduced it, with a passing test underneath it. Not a blog post about the feature. A test you can run.

All three are MIT, all three run in CI on every push, and all three regenerate their own README from the test files so the tables cannot drift away from the code.

The rule that makes it work

The whole thing hangs on one constraint: the toolchain has to be the referee, not the author. It is easy to write a file called records.cs and claim it demonstrates C# 9. It is much harder to write a file that only compiles if the compiler is set to C# 9 and nothing later.

Each language gets a different mechanism for that, and each mechanism has consequences that turn out to be the interesting part.

C#: pin LangVersion and live with it

One project per language version under src/. Each .csproj pins <LangVersion> to that version, so the compiler rejects anything newer. If a file in src/CSharp03 compiles, it really is C# 3.0 syntax. Everything targets .NET 10, so the modern base class library is available everywhere; only the language is frozen.

src/CSharp01/CSharp01.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <!-- Pinned so the compiler rejects anything newer than C# 1.x (ISO-1). -->
  <PropertyGroup>
    <LangVersion>ISO-1</LangVersion>
    <Nullable>disable</Nullable>
    <!-- The SDK-generated TargetFrameworkAttribute file uses 'global::',
         a C# 2 feature, so we write it by hand in AssemblyInfo.cs. -->
    <GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>
    <!-- xUnit2015 wants Assert.Throws<T>(), but generic syntax
         does not exist in C# 1. -->
    <NoWarn>$(NoWarn);xUnit2015</NoWarn>
  </PropertyGroup>

</Project>

Read those comments again, because they are the reward for being strict. Pinning C# 1.x means the .NET SDK itself stops cooperating: the TargetFrameworkAttribute the SDK generates for you is written with global::, and the namespace alias qualifier is a C# 2 feature. The project turns SDK generation off and writes the attribute by hand.

src/CSharp01/AssemblyInfo.cs
// Hand-written replacement for the SDK-generated TargetFramework attribute, which uses the
// C# 2 'global::' namespace alias qualifier and therefore does not compile under ISO-1.
[assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v10.0", FrameworkDisplayName = ".NET 10.0")]

The same pressure shows up in the assertions. C# 1.x and 2.0 have no extension methods, so those projects cannot write actual.Should().Be(expected) and call FluentAssertions statically instead: AssertionExtensions.Should(actual).Be(expected). C# 1.x has no generic syntax either, so Assert.Throws<T>() is out and exceptions get checked with try/catch plus BeOfType(typeof(T)), with the xUnit analyzer warning suppressed on purpose.

None of that is incidental. Those three lines of build configuration are a better explanation of what C# 1.0 actually was than any paragraph I could write about it. You cannot fake them.

At the other end of the timeline, here is C# 14’s field keyword, which finally lets a property have logic without a hand-written backing field:

src/CSharp14/01_FieldKeyword.cs
public class Person
{
    // Normalising setter; the getter is still auto-implemented.
    public string Name { get; set => field = value.Trim(); } = "";

    // Validating setter.
    public int Age
    {
        get;
        set => field = value >= 0
            ? value
            : throw new ArgumentOutOfRangeException(nameof(value), "Age cannot be negative.");
    }

    // Lazily initialised getter. Nullable analysis treats the backing field as `string?`
    // but the property as `string`, because the getter never returns `field` while it is
    // null (a "null-resilient" getter), so this compiles without any nullable warnings.
    public string Greeting => field ??= BuildGreeting();
}

Every file follows the same shape: a // Feature: header, a comment block explaining what the feature is, why it was added, and what you had to write before it existed, then the demonstration code and its xunit tests in the same file. The README’s results table and table of contents are regenerated from the TRX output and those // Feature: headers by a small tools/ReadmeTool project, so the counts in this post came out of a real CI run rather than my memory.

A few features are described but not executed, and the repo says so plainly: embedded interop types, friend assemblies across projects, source generators, and interceptors need tooling or COM rather than language syntax. Being explicit about the gap is worth more than a test that pretends.

JavaScript: no Babel, no polyfills

The JavaScript repo takes the opposite approach, because you cannot pin ECMAScript versions the way you pin LangVersion. Instead the tests run natively on Node with no transpiler and no polyfills, so a passing test means V8 really implements the feature as specified. One folder per edition, one test file per feature, and a doc comment at the top of each file explaining the behaviour and the gotchas.

That choice creates its own set of honest problems. The first is strict mode. Test files are ES modules, which are always strict, so features that only exist in sloppy mode cannot live in them. Those get a .test.cjs extension and run as CommonJS: with, eval, legacy octal literals, arguments.callee, sloppy this.

The second is that engines do not implement everything. ES2015 requires proper tail calls in strict mode. V8 never shipped them. Rather than delete the feature or let it fail forever, the file detects support at run time and reports itself as skipped:

ES2015/proper-tail-calls.test.js
// A tail call is a call that is the very last thing a function does, so its
// result is returned unchanged. `return loop(n - 1)` qualifies.
function loop(n) {
  return n === 0 ? 'done' : loop(n - 1);
}

const supported = (() => {
  try {
    loop(1e6);
    return true;
  } catch (error) {
    if (error instanceof RangeError) return false;
    throw error;
  }
})();

// Node 26 / V8 does not implement proper tail calls, so the deep-recursion
// test is skipped there and the documenting test runs instead.

The same trick covers Iterator.zip, and Math.sumPrecise needs the V8 flag --js-sum-precise to exist at all, so npm test passes it. A repository that claims to test the standard has to be able to say “the standard says this and the engine disagrees” without breaking.

When the engine does keep up, the tests get to be fun. ES2025 iterator helpers, lazily chained over an infinite generator:

ES2025/iterator-helpers.test.js
test('map, filter, take, drop and flatMap are lazy and chainable', () => {
  const result = naturals()
    .filter((n) => n % 2 === 0)
    .map((n) => n * n)
    .drop(1)
    .take(3)
    .toArray();
  expect(result).toEqual([4, 16, 36]);
  expect([1, 2].values().flatMap((n) => [n, n]).toArray()).toEqual([1, 1, 2, 2]);
  // flatMap must return an iterable object per element; a string is rejected as a primitive.
  expect(() => [1].values().flatMap(() => 'ab').toArray()).toThrow(TypeError);
});

Note the last assertion. flatMap rejecting a string is exactly the sort of thing the release notes do not mention and you find out at 2am. That is the whole reason the repo exists.

ES2 and ES5.1 get folders with no features, because they were editorial releases that added no language, and saying so is part of the history. ES2027 holds finished proposals that are waiting for the next edition to be cut. The README is generated by npm run readme and CI fails if it is out of date, which is the small piece of discipline that keeps a generated document trustworthy.

TypeScript: most of the features are not runtime features

TypeScript is the awkward one, and it is the repo I like best. Most of what TypeScript ships is invisible at run time. A test that calls a function and checks the result proves nothing about a conditional type.

So the tests run under ts-jest with full type-checking, which means a type error in a test file fails that file. Type-level behaviour is asserted with expect-type, and the cases that must not compile are pinned with @ts-expect-error, which fails if the error ever stops happening. Both directions are covered:

versions/2.8/conditional-types.test.ts
it("is deferred while the type parameter is unresolved", () => {
  function describe<T>(value: T): TypeName<T> {
    // Inside the generic function the conditional type is deferred: it is
    // still `TypeName<T>`, so no branch has been selected yet and it is not
    // assignable to any single literal.
    const result: TypeName<T> = typeName(value);
    // @ts-expect-error - TypeName<T> is unresolved, so it is not known to be "string"
    const notYetKnown: "string" = result;
    void notYetKnown;
    return result;
  }
  expectTypeOf(describe(true)).toEqualTypeOf<"boolean">();
  expect(describe(true)).toBe("boolean");
});

That second assertion is doing real work. It proves a deferred conditional type is not assignable to a single branch, which is a claim you cannot make by running code.

Then there is the other category: features that are compiler behaviours rather than language behaviours. New flags, emit changes, declaration-emit rules, syntax that cannot run under CommonJS Jest at all such as import.meta or top-level await. Those go through the TypeScript compiler API in memory, via a helpers/compiler.ts that compiles snippets and returns diagnostics, codes, and emitted output.

versions/2.1/downlevel-async-functions.test.ts
const source = `export async function twentyOne() { const a = await Promise.resolve(20); return a + 1; }`;

describe("Downlevel Async Functions", () => {
  it("compiles await into a state machine for ES5", () => {
    const js = transpile(source, { target: ts.ScriptTarget.ES5 });
    expect(js).toContain("__awaiter");
    expect(js).toContain("__generator");
    expect(js).not.toContain("await ");
  });

  it("still behaves like a real async function at run time", async () => {
    const module = run<{ twentyOne(): Promise<number> }>(source, { target: ts.ScriptTarget.ES5 });
    await expect(module.twentyOne()).resolves.toBe(21);
  });

  it("emits await untouched for ES2017 and later", () => {
    expect(transpile(source, { target: ts.ScriptTarget.ES2017 })).toContain("await Promise.resolve(20)");
  });
});

One test asserts on the emitted JavaScript, one runs the emitted module and checks the value, one checks the same source compiles clean at a newer target. Three angles on one feature, none of which a normal unit test could reach.

The version pinning problem shows up here too, in a way I did not expect. Two compilers are installed side by side: TypeScript 6.0.3, the last JavaScript implementation, which ts-jest and the compiler API tests need, and the native TypeScript 7.0.2 under the alias @typescript/native, which ships no JavaScript API in 7.0. CI type-checks every test file with both. And because legacy and standard decorators cannot coexist in one program, versions/1.5/decorators.test.ts gets its own Jest project with experimentalDecorators turned on.

How they were built

Each repository was written by Claude Code running the Fable model, in a terminal, launched with --dangerously-skip-permissions so it never stopped to ask. Each run was 30-plus minutes of continuous unattended work. I was not reviewing diffs as they landed. I was doing something else.

That flag deserves an honest description, because the name is not decoration. Normally Claude Code asks before it writes a file or runs a command. With permissions skipped it does neither, which is what makes a run like this possible: the loop is write a feature file, run the compiler, read the error, fix it, run the tests, move to the next feature, several hundred times, and an approval prompt every few seconds would make it useless. I run it that way in throwaway repositories with nothing sensitive in them and no credentials on the box. I would not point it at a client codebase.

What the model actually earned in those 30 minutes is the stuff I keep pointing at above. Nobody told it that C# 1.x cannot compile the SDK’s generated attribute. It found out the way you would, by trying to build and reading the error, and then it wrote the workaround and left a comment explaining why the workaround exists. Same with the FluentAssertions static calls, the sloppy-mode .cjs split, the runtime feature detection for tail calls, and the decorators project living on its own. Those are the decisions that separate a repository that compiles from a repository worth reading, and they came out of the compiler pushing back.

The parts that were mine: deciding that the version has to be enforced rather than asserted, deciding that the README is generated and CI-gated, and deciding what “a feature” means in each language, which is the official release-notes heading for TypeScript, the edition’s feature list for JavaScript, and the version history for C#. Those are cheap decisions to make and expensive to change later, which is more or less the definition of the work that stays with the human.

What it is good for

An executable changelog is a different object than a changelog. Three things fall out of it:

  • You can check what your runtime really does. Clone, npm test, and the skipped tests tell you exactly where your engine diverges from the spec you were promised.
  • You can date a feature. If you are stuck on an older target, the folder structure answers “can I use this?” in one glance, and the file tells you what people wrote before it existed.
  • You can read the edges. Every file was written by trying the wrong thing on purpose until the toolchain complained, which is where the useful behaviour lives.

Pin the version so the compiler is the referee. Generate the docs from the tests. Skip loudly when the runtime disagrees with the spec.

All three repositories are public: csharp-language-features, javascript-language-features, and typescript-language-features. Clone one, run the suite, and open the folder for whichever version you are stuck on.