How Code Becomes Runnable: A Map of Compilers, Interpreters, JIT and AOT
There is no such thing as a compiled or an interpreted language; which stages run at build time and which at run time is the implementation's choice. A C# method called 1,500 times is compiled by the JIT only three times, TypeScript types are checked at build time and erased, and V8 first interprets code and then compiles it with Maglev and TurboFan. The post shows the compiler pipeline with real output and drafts the grammar of the Bookstore filter language we build in this series.
Version note: The code in this post was built and run with .NET 10 (SDK 10.0.404), TypeScript 7.0.2 and Node.js 26 (V8 14.6); the decompiler output comes from ilspycmd 11.2.
Guess First
We write a small C# program for Bookstore, the online bookstore used as the shared example across this site's series: a one-line method called IsCheap returns true for books priced under 300 TRY. The program calls it 1,500 times over three books. The question: how many times does .NET's JIT compiler compile IsCheap? 1,500 times, once, or some other number?
The second question is about TypeScript. A field declared as price: number receives the string "349.90" from an API, and the code adds 25 TRY of shipping to it. Does the TypeScript compiler report an error? If not, what does the program print when it runs? We will see both answers with real output in the middle of the post; write your guesses down.
The Stages Inside a Compiler
A compiler's job is to translate text written in one language into another form that carries the same meaning. The target can be machine code, another language (TypeScript to JavaScript), or the instructions of a virtual machine (.NET's IL). Whatever the language, the work goes through roughly the same stages:
- Lexing: splitting the characters into tokens.
price<300is three tokens: an identifier, an operator and a number. - Parsing: arranging the tokens into a tree according to the grammar. The result is an AST, the tree that keeps the meaningful structure of the code and drops details such as whitespace and parentheses.
- Semantic analysis: binding names to their declarations and checking types. Which property is
price, between which types does<operate, and what is the type of the result? - Producing an IR, an intermediate form that sits between the source and machine code and is independent of both the language and the processor.
- Optimization: removing unnecessary work on the IR, computing constants ahead of time, inlining small methods where they are called.
- Code generation: going from the IR to the target code.
The first three stages are called the front end, the last two the back end. The front end knows the language but not the processor; the back end knows the processor but not the source language. The IR is the contract between them. Why have a separate IR at all? Take five languages and four processor architectures: writing a separate compiler for each pair means twenty compilers. With a shared IR in between, five front ends and four back ends are enough. LLVM is the best-known example of this idea; Rust, Swift and Clang share the same back end. In .NET, C#, F# and Visual Basic all compile to the same IL, and the same JIT turns that IL into x64 or Arm64 code.
These stages do not all have to run at the same time or in the same place. That is where the real difference lies: which stage runs at build time and which one runs while the application is running?
Three Languages, Three Paths

Let's walk through the three rows of the figure with real output. All the code lives in the series' samples/ folder.
C#: From Roslyn to IL, From IL to Machine Code
Book[] books =
[
new("Dune", 349.90m),
new("Emma", 199.00m),
new("The Hobbit", 289.50m),
];
int matches = 0;
for (int round = 1; round <= 5; round++)
{
for (int i = 0; i < 100; i++)
{
foreach (Book book in books)
{
if (Filters.IsCheap(book))
matches++;
}
}
// give the background tiering thread time to recompile hot methods
Thread.Sleep(200);
}
Console.WriteLine($"IsCheap calls: {5 * 100 * books.Length}, matches: {matches}");
public sealed record Book(string Title, decimal Price);
public static class Filters
{
[MethodImpl(MethodImplOptions.NoInlining)]
public static bool IsCheap(Book book) => book.Price < 300m;
}
When dotnet build runs, Roslyn, the C# compiler, parses the source, binds names and types and then writes IL into the assembly (.dll). There is no machine code in the assembly. Let's look at the IL of IsCheap with ilspycmd, a decompiler installed with dotnet tool install ilspycmd:
$ ilspycmd -il -m "M:Filters.IsCheap(Book)" samples/PipelineTour/bin/Release/net10.0/PipelineTour.dll
// IL code: PipelineTour
.method public hidebysig static
bool IsCheap (
class Book book
) cil managed noinlining
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
01 00 01 00 00
)
// Method begins at RVA 0x233b
// Header size: 1
// Code size: 22 (0x16)
.maxstack 8
IL_0000: ldarg.0
IL_0001: callvirt instance valuetype [System.Runtime]System.Decimal Book::get_Price()
IL_0006: ldc.i4 300
IL_000b: newobj instance void [System.Runtime]System.Decimal::.ctor(int32)
IL_0010: call bool [System.Runtime]System.Decimal::op_LessThan(valuetype [System.Runtime]System.Decimal, valuetype [System.Runtime]System.Decimal)
IL_0015: ret
} // end of method Filters::IsCheap
Six instructions, all working on a stack: push the argument, call the Price getter, build a decimal from 300, call op_LessThan, return the result. Two things stand out. First, the < operator is now a method call; Roslyn's semantic analysis decided at build time which operator to use for decimal. Second, even the 300m literal is a call to a decimal constructor in IL. We will come back to these details in post 05 of the series, on IL and the JIT.
So when does this IL become machine code? Let's ask the runtime to print every method it compiles. DOTNET_JitDisasmSummary is a diagnostic setting that also works on the Release runtime; DOTNET_JitStdOutFile sends the output to a file (in PowerShell you set them as $env:DOTNET_JitDisasmSummary=1):
$ dotnet build -c Release samples/PipelineTour
$ DOTNET_JitStdOutFile=jit.txt DOTNET_JitDisasmSummary=1 dotnet samples/PipelineTour/bin/Release/net10.0/PipelineTour.dll
IsCheap calls: 1500, matches: 1000
$ grep -E 'IsCheap|get_Price|Main' jit.txt
1: JIT compiled Program:<Main>$(System.String[]) [Instrumented Tier0, IL size=234, code size=1156]
3: JIT compiled Filters:IsCheap(Book) [Tier0, IL size=22, code size=128]
4: JIT compiled Book:get_Price() [Tier0, IL size=7, code size=40]
5: JIT compiled Filters:IsCheap(Book) [Instrumented Tier0, IL size=22, code size=128]
6: JIT compiled Book:get_Price() [Instrumented Tier0, IL size=7, code size=40]
11: JIT compiled Filters:IsCheap(Book) [Tier1, IL size=22, code size=212]
12: JIT compiled Book:get_Price() [Tier1, IL size=7, code size=40]
Here is the answer to the first guess: for 1,500 calls, IsCheap was compiled three times. The JIT compiles a method the first time it is called, and the machine code it produces is used until the process exits; later calls go straight to that code. The three lines come from tiered compilation. The first compilation is Tier0: fast, with no optimizations. Once the method has been called 30 times it counts as frequently used; for Dynamic PGO the runtime first compiles an instrumented Tier0 copy that counts what happens, and once it has enough data it compiles the optimized Tier1 version in the background. The code size values depend on the processor architecture; the order and the count do not.
Main starts directly with an instrumented Tier0 version because of its loops. To move a method that is called once but runs for a long time to Tier1, the runtime uses OSR, switching a running loop over to optimized code in the middle. Tiers, OSR and PGO are the topic of post 05; the point here is that in C# the JIT compiles a few times per method, not on every call.
.NET has a second path: Native AOT. With PublishAot on, ILC, the compiler that turns IL into machine code for Native AOT, compiles the whole application during publish; the resulting executable has no JIT. Its cost, and middle-ground options such as ReadyToRun, are in post 06.
TypeScript: Type Checked, Then Erased
interface Book {
title: string;
price: number;
}
function isCheap(book: Book): boolean {
return book.price < 300;
}
function withShipping(book: Book): number {
return book.price + 25;
}
// the API sent price as a string; the cast silences the compiler
const fromApi = JSON.parse('{"title":"Dune","price":"349.90"}') as Book;
console.log(isCheap(fromApi));
console.log(withShipping(fromApi));
$ npx tsc --version
Version 7.0.2
$ npx tsc
$ cat dist/book.js
function isCheap(book) {
return book.price < 300;
}
function withShipping(book) {
return book.price + 25;
}
// the API sent price as a string; the cast silences the compiler
const fromApi = JSON.parse('{"title":"Dune","price":"349.90"}');
console.log(isCheap(fromApi));
console.log(withShipping(fromApi));
export {};
$ node dist/book.js
false
349.9025
The answer to the second guess: tsc reported nothing, and the program printed 349.9025 instead of 374.9. The TypeScript compiler has two jobs. First it type checks; then it removes all type information and emits JavaScript. Neither the interface nor : number survives in dist/book.js. This is called type erasure. Nothing checks at run time whether price is really a number. The as Book cast converts nothing either; it only tells the compiler "trust me". And the + operator, given a string and a number, concatenates them as JavaScript's rules say.
The TypeScript 7 compiler was rewritten in Go. That means the compiler itself runs natively; the JavaScript it emits is the same. The details are in the TypeScript Advanced series post "TypeScript 7 and Beyond: The Go-Based Compiler and the New Defaults". Node.js has also been able to run .ts files directly by stripping their types since versions 22.18 and 23.6: node src/book.ts prints the same two lines, again without checking any types.
JavaScript: V8's Interpreter and Its JIT Tiers
JavaScript has no build step (leaving bundlers and minifiers aside). The source goes to the engine as it is. V8, the engine of Node.js and Chrome, first parses the code into bytecode and runs it in its interpreter, Ignition:
function isCheap(book) {
return book.price < 300;
}
const books = [
{ title: "Dune", price: 349.9 },
{ title: "Emma", price: 199 },
{ title: "The Hobbit", price: 289.5 },
];
let matches = 0;
for (let i = 0; i < 200_000; i++) {
if (isCheap(books[i % books.length])) matches++;
}
console.log(`isCheap calls: 200000, matches: ${matches}`);
$ node --print-bytecode --print-bytecode-filter=isCheap samples/v8-tiers/hot.js
[generated bytecode for function: isCheap (0x2fbef05e8061 <SharedFunctionInfo isCheap>)]
Bytecode length: 14
Parameter count 2
Register count 1
Frame size 8
39 S> 0x3d5a20ef87b0 @ 0 : 33 03 00 00 GetNamedProperty a0, [0:"price"], FBV[0]
0x3d5a20ef87b4 @ 4 : d2 Star0
0x3d5a20ef87b5 @ 5 : 00 0d 2c 01 LdaSmi.Wide [300]
45 E> 0x3d5a20ef87b9 @ 9 : 77 f9 00 00 TestLessThan r0, EmbeddedFeedback[0x0]
51 S> 0x3d5a20ef87bd @ 13 : b7 Return
This bytecode is V8's counterpart of IL in C#: an intermediate form, independent of the source text, that the interpreter executes. The difference is where it is produced. IL is written to disk at build time; bytecode is produced in memory on every run. The FBV and EmbeddedFeedback entries are feedback slots: the interpreter records there which object shapes price was read from and which types the comparison saw. The later tiers compile based on those records:
$ node --trace-opt samples/v8-tiers/hot.js 2>&1 | grep -E 'isCheap|calls'
[marking 0x30b452f6f759 <JSFunction isCheap (sfi = 0x299a30568061)> for optimization to MAGLEV, ConcurrencyMode::kConcurrent, reason: hot and stable]
[compiling method 0x30b452f6f759 <JSFunction isCheap (sfi = 0x299a30568061)> (target MAGLEV), mode: ConcurrencyMode::kConcurrent]
[completed compiling 0x299a3056adc9 <JSFunction isCheap (sfi = 0x299a30568061)> (target MAGLEV) - took 0.000, 0.094, 0.004 ms]
[marking 0x299a3056adc9 <JSFunction isCheap (sfi = 0x299a30568061)> for optimization to TURBOFAN_JS, ConcurrencyMode::kConcurrent, reason: hot and stable]
[compiling method 0x299a3056adc9 <JSFunction isCheap (sfi = 0x299a30568061)> (target TURBOFAN_JS), mode: ConcurrencyMode::kConcurrent]
[completed compiling 0x299a3056adc9 <JSFunction isCheap (sfi = 0x299a30568061)> (target TURBOFAN_JS) - took 0.102, 7.268, 0.004 ms]
[completed optimizing 0x299a3056adc9 <JSFunction isCheap (sfi = 0x299a30568061)> (target TURBOFAN_JS)]
isCheap calls: 200000, matches: 133333
According to the current overview on the V8 blog, the tiers are: the Ignition interpreter; Sparkplug, a baseline compiler that turns bytecode into machine code very quickly without optimizing; Maglev, the mid-tier optimizing compiler that shipped with Chrome 117 in 2023; and TurboFan, the tier that compiles slowest but produces the fastest code. In the output, isCheap moves up to Maglev and then to TurboFan. The decision depends on timing; run the same command a few times and you will sometimes see it stay at Maglev.
V8's biggest difference from .NET is that it speculates. TurboFan looks at the feedback and generates code assuming "price has always been a number". If a string shows up one day, the optimized code is thrown away and the function falls back to bytecode; this is called deoptimization. In C# the types are known at build time, so the JIT does not need that kind of assumption. The guesses Dynamic PGO does make (for example, which class is usually behind an interface call) are protected by a check that falls back when the guess is wrong.
There Is No Such Thing as a "Compiled Language"
You often hear "C# is a compiled language, JavaScript is an interpreted one". The output above shows that the distinction belongs to the implementation, not the language. In V8, JavaScript is both interpreted and compiled by three separate compilers. C# is compiled to IL by Roslyn, and the IL is turned into machine code either by the JIT or by Native AOT.
The same C# code can also run where it is interpreted. According to the Blazor WebAssembly documentation on Microsoft Learn, a Blazor WebAssembly app without AOT runs on an IL interpreter written in WebAssembly; turning on RunAOTCompilation compiles the .NET code straight to WebAssembly. Python is the same: CPython interprets bytecode, PyPy compiles the same code with a JIT. A language defines what a program means; how that meaning is carried out on a machine is the implementation's choice.
More useful questions are these: When are type errors caught, at build time or at run time? When is machine code produced? Can the optimizer see data from the running program? In this series we will look at every stage through these questions.
The Series Project: A Bookstore Filter Language
Concepts are hard to learn by reading alone, so throughout the series we will write a small language. On Bookstore's catalog page and in the admin panel, users should be able to filter books with a short query:
price < 300 and author = "Jane Austen"
stock = 0 or (year < 1900 and not title contains "The")
Let's write the requirements down first, because each of them will turn into a compiler decision later:
- Fields:
title,author,isbn,price,year,stock. An unknown field is rejected with the line and column where it appears. - Operators:
=,!=,<,<=,>,>=,contains; logicaland,or,not; parentheses for grouping. Keywords are case-insensitive. - Literals: numbers (the decimal separator is always a dot, regardless of the machine's language settings), double-quoted strings,
true/false. - Type errors such as
price = "abc"are caught before the filter is applied to any book. - The same filter runs in two places: on an in-memory list of books and on SQL Server through EF Core. User input is never added to SQL as a string.
- Malicious or broken input (a very long filter, thousands of nested parentheses) does not crash the process.
The first draft of the grammar in EBNF looks like this (docs/grammar.ebnf):
filter = expr , EOF ;
expr = or_expr ;
or_expr = and_expr , { "or" , and_expr } ;
and_expr = not_expr , { "and" , not_expr } ;
not_expr = "not" , not_expr | comparison ;
comparison = primary , [ comp_op , primary ] ;
primary = field | literal | "(" , expr , ")" ;
comp_op = "=" | "!=" | "<" | "<=" | ">" | ">=" | "contains" ;
literal = number | string | "true" | "false" ;
field = letter , { letter | digit | "_" } ;
number = digit , { digit } , [ "." , digit , { digit } ] ;
string = '"' , { string_char | '\"' | '\\' } , '"' ;
Read the rules from top to bottom and the precedence appears: or binds loosest, then and, then not, and the comparison binds tightest. So a or b and c means a or (b and c). The last three rules (field name, number, string) are the lexer's job, the ones above are the parser's. The roadmap follows from this: the lexer in post 02; the parser, the AST and an interpreter that runs the AST directly in 03; type checking and code generation that produces both an in-memory delegate and SQL from the same AST in 04. Posts 05–07 move to the runtime side (JIT, AOT, garbage collection), and 08 looks at how languages evolve.
In Production
The first requests of a JIT-compiled application are slower than the later ones. Every method is compiled on its first call and starts with unoptimized Tier0 code. That is why the latency graph of the first minute after a deploy looks different. Treat warm-up separately when you measure; if startup time matters, the ReadyToRun and Native AOT options are covered in post 06.
TypeScript types do not protect you at the edge of the system. Every piece of data coming from an API, a queue or localStorage must be validated at run time. A cast with as is not validation. Use a schema library or a hand-written check at the edge, and derive the type from that check.
Do not leave diagnostic output switched on in production. DOTNET_JitDisasmSummary and V8's --trace-opt flag exist for learning and for investigating a problem; they write output on every compilation and fill the log with lines nobody needs.
Common Pitfalls
Believing "TypeScript types are checked at run time". Types are checked at build time and erased; the 349.9025 above is the result. At run time, type information exists only if you write a check.
Saying "C# is interpreted" or the opposite, "C# compiles straight to machine code". In a normal .NET application the build produces IL and the machine code comes from the JIT at run time. With Native AOT the machine code is produced during publish. A project setting decides which one is used, not the language.
Believing "the JIT compiles on every call". As the output shows, there are a few compilations per method; after that the same machine code is reused. The JIT's cost is paid on the first calls.
Interpreting performance in a Debug build. In Debug the JIT's optimizations are off and tiered compilation does not work the same way. Do every performance measurement in a Release build.
A Small Experiment
★☆☆ Run PipelineTour with DOTNET_TieredCompilation=0 and look at jit.txt again. How many times, and with which label, was IsCheap compiled? Think about what that means for startup time.
★★☆ Remove the as Book cast in book.ts and give fromApi the type unknown. On which lines does tsc report errors? Write a type guard that checks price really is a number and show that the 349.9025 problem goes away.
★★★ In the loop in hot.js, send a book whose price is a string after the 100,000th call. Find the deoptimization line in the output of node --trace-opt --trace-deopt and read the reason it gives.
In the next post we write the first stage of the pipeline: a lexer for the Bookstore filter language in C# that runs in a single pass over a ReadOnlySpan<char>, knows the line and column of every token and can say "unterminated string at line 1, column 9" when something goes wrong.