How Programming Languages Evolve: Paradigms from Lisp to C# and the Road to Runtime Async
Language features are often born as a trick the compiler imitates with existing constructs, and once they spread the runtime supports them directly: generics, lambdas and .NET 11's Runtime Async are examples. A short family tree from Fortran and Lisp to C#, paradigms used together in C#, static/dynamic and strong/weak typing, the nominal versus structural difference between C# and TypeScript, and a live stack that drops from 13 lines to 5 with Runtime Async, all with real output.
Version note: The code in this post was built with .NET 10 (SDK 10.0.404) and verified with xUnit v3 4.0.3 tests; the Runtime Async example was run with .NET 11 (SDK 11.0.100), the TypeScript example with TypeScript 7.0.2 and Node.js 26.
Guess First
In the last post of the series we step back and look at how languages change. Two questions first. For Bookstore, the online bookstore used as the shared example across this site's series, we write the order flow as three async methods: PlaceOrderAsync awaits ReserveStockAsync, which awaits ChargeAsync. The innermost method prints the call stack before any await has suspended:
await PlaceOrderAsync("9780441172719", 2);
static async Task PlaceOrderAsync(string isbn, int quantity)
{
await ReserveStockAsync(isbn, quantity);
await Task.Yield();
}
static async Task ReserveStockAsync(string isbn, int quantity)
{
await ChargeAsync(quantity * 349.90m);
await Task.Yield();
}
static async Task ChargeAsync(decimal amount)
{
// the live call stack while the payment is being charged, before any await has suspended
Console.WriteLine(new StackTrace());
await Task.Yield();
}
First question: there are four methods in the call chain (including Main). How many lines do you see in the printed stack? Does the number change when .NET 11's Runtime Async is turned on?
Second question: in TypeScript, if you pass an API response that is never declared as a Book but has the same fields to a function that expects a Book, what does the compiler say? And if you try the same in C# with two record types?
A Short Family Tree
Most of the ideas we saw in this series appeared between the late 1950s and the 1970s. Fortran (1957), developed by John Backus's team at IBM, became the first widely used high-level language and one of the first optimizing compilers. John McCarthy's Lisp (1958) brought code as data and garbage collection; the expression trees from post 04 and the garbage collector from post 07 are descendants of that line. The 1960 report of ALGOL defined the language's syntax with BNF; the EBNF we used for the filter language's grammar in post 03 is its extended form. ALGOL's block structure and lexical scope passed into most of today's languages through C (1972), which Dennis Ritchie wrote at Bell Labs.
Object-oriented programming started with the classes of Simula 67 and turned into the idea of "everything is an object and a message" with Smalltalk, developed by Alan Kay at Xerox's research center in Palo Alto; C++, Java (1995) and C#, whose 1.0 release came out in 2002, came down this road. The Self language, derived from Smalltalk, is among the sources of the hidden class, inline cache and deoptimization ideas of runtimes like the V8 from post 05. On the functional side, Robin Milner's ML spread type inference and pattern matching, and Haskell brought pure functions and lazy evaluation into the mainstream; F#, developed by Don Syme at Microsoft Research, carried the ML family to .NET. C#'s LINQ, records and pattern matching carry the influence of this family.
Paradigms Are Not Rivals
A paradigm is the set of concepts you think about a problem with: in imperative programming you write steps and changing state, in OOP objects sending messages to each other, in functional programming functions that return values and do not change the outside world, and in declarative programming you write "what" you want instead of "how". SQL and the filter language from post 03 are declarative; the user says which books they want, not how to scan them. Modern C# offers all of these together. Let's write the same question, the worth of an author's books in stock, in three ways:
// imperative: say how, step by step, by changing a variable
public static decimal Imperative(IEnumerable<Book> books, string author)
{
decimal total = 0;
foreach (Book book in books)
{
if (book.Author == author)
total += book.Price * book.Stock;
}
return total;
}
// declarative and functional: say what, with LINQ and lambdas
public static decimal Declarative(IEnumerable<Book> books, string author) =>
books.Where(b => b.Author == author).Sum(b => b.Price * b.Stock);
// pattern matching over immutable records
public static decimal Matched(IEnumerable<Book> books, string author) =>
books.Sum(book => book switch
{
{ Stock: > 0 } when book.Author == author => book.Price * book.Stock,
_ => 0m,
});
$ dotnet run --project samples/Paradigms
2998.00 2998.00 2998.00
System.Collections.Generic.List`1[Bookstore.Catalog.Book]
All three give the same result, and a test checks this for three authors. Which one to pick depends on the context: in a loop that runs often, the imperative version does not allocate (as we saw in posts 05 and 07, lambdas and enumerators are not free), the LINQ version states the intent more clearly, and pattern matching keeps rules that decide by shape readable. The IBookRule interface from post 05 is the OOP face of the same problem: it spreads behaviour over a class hierarchy.
Type Systems: Static, Dynamic, Strong, Weak
Type systems have to be discussed on two separate axes. The first is when types are checked: with static typing the compiler checks them (C#, TypeScript, Go), with dynamic typing values are checked at run time (JavaScript, Python). The second is how freely the language converts incompatible types: with weak typing the language guesses and converts, with strong typing it reports an error. The axes are independent: Python is dynamic but strong, "3" + 2 raises an error; JavaScript is dynamic and weak:
$ node -p '"3" * 2'
6
$ node -p '"349.90" + 25'
349.9025
The second line is the very bug from post 01: + concatenates when it meets a string, while * converts the string to a number. Let's see where static typing catches the error with a small sample that compiles C# code with Roslyn (the same method as the SemanticModel sample in post 04):
const string Code = """
public sealed record Book(string Isbn, string Title, decimal Price);
public sealed record BookDto(string Isbn, string Title, decimal Price);
public static class Shipping
{
public static decimal WithShipping(Book book) => book.Price + 25m;
public static decimal FromApi(BookDto dto) => WithShipping(dto);
public static int Twice() => "3" * 2;
}
""";
$ dotnet run --project samples/NominalTyping
(8,64): error CS1503: Argument 1: cannot convert from 'BookDto' to 'Book'
(10,34): error CS0019: Operator '*' cannot be applied to operands of type 'string' and 'int'
CS0019 rejects at compile time the expression for which JavaScript silently produced 6. Type inference reduces the typing burden of static typing: in C# var and lambda parameters, in TypeScript return types are worked out by the compiler. The ML family takes this furthest; in F# you can write fully statically typed code without writing a single type of a function.
Nominal and Structural Typing: C# and TypeScript
The CS1503 above is the C# half of the second guess. Book and BookDto have the same three properties, but C# uses a nominal type system: for one type to stand in for another, the relationship between them must be written down by name (inheritance, interface implementation, a defined conversion). TypeScript is structural: it looks at the shape.
interface Book {
isbn: string;
title: string;
price: number;
}
// what the API returns: never declared as a Book, but it has the same shape
type BookDto = { isbn: string; title: string; price: number; currency: string };
function withShipping(book: Book): number {
return book.price + 25;
}
const fromApi: BookDto = { isbn: "9780441172719", title: "Dune", price: 349.9, currency: "TRY" };
console.log(withShipping(fromApi));
$ cd samples/ts-erasure && npx tsc && node dist/structural.js
374.9
tsc reported no error: BookDto has every field Book asks for, and the extra currency field is no problem. Both approaches have their reasons. TypeScript added types to a world full of JSON objects whose shape is known but whose class is not; structural typing is natural there. In C#, on the other hand, Book and BookDto having the same shape can be a coincidence: one is the domain model, the other an API contract, and they will drift apart. Nominal typing forces that separation into an explicit mapping. There is also a run-time difference: as we saw in post 01, TypeScript's types are erased completely after compilation, while C#'s types live on in metadata and at run time.
From Compiler Trick to Runtime Feature
A language feature can arrive in two ways: the compiler translates it into existing constructs, or the runtime supports it directly. Generics are a good example. Java 5's generics stayed in the compiler: type arguments are erased after compilation (type erasure), and at run time a List<String> and a List<Integer> are the same class. .NET 2.0 added generics to the runtime; the type argument is known at run time too:
// .NET generics are kept at run time (reified); TypeScript and Java erase them
Console.WriteLine(new List<Book>().GetType());
The second line of the Paradigms output above comes from this line: the type exists at run time together with its argument Book. That is why in .NET a List<int> holds values without boxing and the JIT can produce separate, specialized machine code for each value type. Lambdas go the other way round: the C# compiler turns every lambda into a closure class and a delegate; for years the runtime saw them as two ordinary objects. In post 05 we measured that .NET 10's JIT moves a delegate that does not escape to the stack while leaving the closure on the heap: the compiler's trick did not change, the runtime started to understand it.
The Journey of async/await: From State Machines to Runtime Async
async/await arrived in C# 5 (2012) as a compiler feature. The compiler turns every async method into a state machine struct; every await is a state, the locals are stored in the struct's fields when the method is suspended, and resuming is done with a callback. The mechanics are covered in the C# Async series post "How C# async/await Works: The State Machine and Continuations". In this model the runtime knows nothing about async; all it sees are ordinary methods and structs.
According to the what's new page on Microsoft Learn, .NET 11 brings Runtime Async: suspension and resumption are managed by the runtime itself instead of by state machines generated by the compiler. In .NET 11 it is a feature you turn on by adding <Features>runtime-async=on</Features> to the project file; it no longer needs EnablePreviewFeatures, and .NET's own libraries are compiled with this model. In the sample project we tied it to an MSBuild property and compiled the same code both ways (the sample has its own global.json that selects the .NET 11 SDK):
<PropertyGroup Condition="'$(RuntimeAsync)' == 'true'">
<Features>runtime-async=on</Features>
</PropertyGroup>
$ cd samples/RuntimeAsync
$ dotnet --version
11.0.100
$ dotnet build -c Release -p:RuntimeAsync=false --no-incremental -v q | grep -E 'succeeded|error'
Build succeeded.
$ dotnet bin/Release/net11.0/RuntimeAsync.dll
at Program.<<Main>$>g__ChargeAsync|0_2(Decimal amount)
at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine)
at Program.<<Main>$>g__ChargeAsync|0_2(Decimal amount)
at Program.<<Main>$>g__ReserveStockAsync|0_1(String isbn, Int32 quantity)
at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine)
at Program.<<Main>$>g__ReserveStockAsync|0_1(String isbn, Int32 quantity)
at Program.<<Main>$>g__PlaceOrderAsync|0_0(String isbn, Int32 quantity)
at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine)
at Program.<<Main>$>g__PlaceOrderAsync|0_0(String isbn, Int32 quantity)
at Program.<Main>$(String[] args)
at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine)
at Program.<Main>$(String[] args)
at Program.<Main>(String[] args)
$ ilspycmd -l s bin/Release/net11.0/RuntimeAsync.dll
Struct Program+<<<Main>$>g__ChargeAsync|0_2>d
Struct Program+<<<Main>$>g__PlaceOrderAsync|0_0>d
Struct Program+<<<Main>$>g__ReserveStockAsync|0_1>d
Struct Program+<<Main>$>d__0
$ dotnet build -c Release -p:RuntimeAsync=true --no-incremental -v q | grep -E 'succeeded|error'
Build succeeded.
$ dotnet bin/Release/net11.0/RuntimeAsync.dll
at Program.<<Main>$>g__ChargeAsync|0_2(Decimal amount)
at Program.<<Main>$>g__ReserveStockAsync|0_1(String isbn, Int32 quantity)
at Program.<<Main>$>g__PlaceOrderAsync|0_0(String isbn, Int32 quantity)
at Program.<Main>$(String[] args)
at Program.<Main>(String[] args)
$ ilspycmd -l s bin/Release/net11.0/RuntimeAsync.dll
The answer to the first guess: 13 lines with the classic compilation. Every async method appears twice on the stack, with AsyncMethodBuilderCore.Start in between, which starts the state machine, because the method called from outside is only a launcher and the real body lives in the state machine's MoveNext. ilspycmd lists one state machine struct per method. With Runtime Async the stack drops to five lines: the call chain you wrote, as it is. Not a single state machine is left in the assembly. This difference changes what profilers, the debugger's call stack window and logs that read the live stack see. According to Learn, exception stack traces already looked the same in both models, because the classic model cleans them up separately; the gain is in the live stack.
This is the newest form of a pattern we have seen since the start of the series: a feature is born as a trick the compiler imitates with existing constructs, and as it spreads, the runtime starts to understand it directly.
Production Notes
Measure before turning on Runtime Async in a project that moves to .NET 11. The page on Learn says the team is asking for feedback on throughput and library size changes; so the feature works, but its effect on every kind of workload has not yet settled through wide experience. Publishing an async-heavy service with ReadyToRun or Native AOT is supported as well; but, as with the measurements in posts 05 and 06, do not decide without seeing your own service's numbers. Also check that your profiler and APM tools show the new stack shape correctly.
Upgrade language versions with the same care. A new C# feature usually lives in the compiler and can also work on older runtimes; features that rely on the runtime, like Runtime Async, require moving the target framework (net11.0). Knowing which side a feature belongs to also shapes the upgrade plan.
Common Pitfalls
Treating paradigms as rivals. "OOP is dead, everything should be functional", or the opposite, presents tools that live side by side in the same codebase as alternatives to each other. In Bookstore, order statuses are best expressed as a state machine, catalog queries declaratively, price calculations functionally over immutable records, and payment providers with interfaces in OOP style. The question to ask is not "which paradigm" but "how is this problem written most readably".
Reducing language comparisons to benchmarks. The sentence "language X is faster than Y" usually measures not the language but the implementation and the code that was written. In post 06 the same C# code came out ten times slower on JIT than on Native AOT; the reason was not the language but our code compiling on every request. The distinction from post 01 applies here too: speed is a property not of a language but of the compiler, the runtime and the code. When choosing a language, the type system, the ecosystem, the tools and the team's experience matter at least as much as raw speed.
Relying on structural typing and mixing up contracts. Being able to silently use an API DTO in place of a domain type in TypeScript means that when the two types drift apart, the error shows up somewhere far away. At the boundaries you need explicit mapping and run-time validation (for example a JSON schema); since the types are erased, TypeScript does not do this for you.
A Small Experiment
★☆☆ In the Runtime Async sample, move the Console.WriteLine(new StackTrace()) line in ChargeAsync below await Task.Yield() and run both builds again. Which methods are left on the stack, and why?
★★☆ In samples/ts-erasure, pass the object literal directly to withShipping instead of the fromApi variable. What did tsc say about currency this time? Explain how excess property checking relates to structural typing.
★★★ Extend the filter language with a new construct: add a list literal such as author in ["Jane Austen", "George Eliot"]. What changed in the lexer, the parser, the binder and code generation? Compare the interpreter, the compiled delegate and the SQL produced by Entity Framework Core in the same test.
The series ends here. We took Bookstore's filter language from characters to tokens, from tokens to a tree, from the tree to a verified bound tree and from there both to a delegate and to SQL; then we measured how the same path continues in .NET through intermediate code, the JIT, ahead-of-time compilation and garbage collection. When you run into a language feature or a performance problem, you can now ask this question: at which stage of the pipeline is this work done, at build time or at run time? The answer usually tells you what to do as well.