How TypeScript works
Follow a .ts file through the TypeScript compiler: how it checks types, writes plain JavaScript, still writes it when there are errors, and what it can't check.
- 01Name the compiler's two jobsExplain how tsc checks your types and then writes plain JavaScript without them.
- 02Predict what runsSay what tsc writes for a file, why types never change what the code does, and why code with errors still runs.
- 03Pick the right checkTell which mistakes a static check catches before the code runs and which need a runtime check while it runs.
One command, two jobs
In the last lesson you saw TypeScript mark a mistake while you typed, and you saw that the browser runs plain JavaScript. This lesson follows one file through that process, step by step. Here is the price bug again, saved in a file called cart.ts:
const teaPrice: number = 3;const cakePrice: number = "4";console.log(teaPrice + cakePrice);Line 1 says teaPrice is a number and gives it 3. Line 2 says cakePrice is a number too, but gives it the text "4". Line 3 adds the two and prints the result. The error on line 2 is on purpose: it's the bug this lesson follows.
The program that turns TypeScript into JavaScript is the TypeScript compiler. Its command is tsc (the next lesson installs it). A compiler is a program that reads code in one form and writes it in another. You give it the file name in a terminal, the window where you type commands:
tsc --target es2022 cart.tstsc is the command, and cart.ts is the file to compile. --target es2022 asks for modern JavaScript, so the output looks like the code you wrote; the emitting section shows what happens without it. Here is what tsc prints:
cart.ts:2:7 - error TS2322: Type 'string' is not assignable to type 'number'.
2 const cakePrice: number = "4";
~~~~~~~~~
Found 1 error in cart.ts:2Read the terminal output one line at a time:
- Line 1 names the file, line, and column (
cart.ts:2:7means line 2, character 7), then the error numberTS2322and the message: text is going where a number has to go. - Lines 3 and 4 copy line 2 of your code and draw
~~~~~~~~~under the name the error is about. - The last line counts the errors and says where they are.
That's the first job: checking. But tsc also did a second job, quietly. A new file appeared next to cart.ts:
const teaPrice = 3;
const cakePrice = "4";
console.log(teaPrice + cakePrice);This is the second job, called emitting: writing JavaScript. Lines 1 and 2 have lost their : number. Everything else is your code, exactly as you wrote it, error and all. Press Edit & run and it prints 34, the same bug as before.
tsc command does two jobs with cart.ts. It checks the types and prints the error on line 2. It also writes cart.js, the same code without the types.A small print shop gets your draft. A proofreader reads it and circles mistakes. Then the printer prints the pages, without the notes in the margin. Unless you say "wait until it's fixed", the printer prints whatever is on the page, circled mistakes and all.
- In real life: Your draft, with notes in the margin
- In TypeScript:
cart.ts, with types - In real life: The proofreader marks mistakes
- In TypeScript: Checking:
tscreports errors - In real life: The printer prints the pages without the margin notes
- In TypeScript: Emitting:
tscwritescart.jswithout the types - In real life: The printer prints even if a mistake is still marked
- In TypeScript:
cart.jsis written even when there are errors
The TypeScript compiler does two separate jobs. Checking reads your code and reports type errors. Emitting writes plain JavaScript: your code with the types removed. Neither job runs your code. Something else runs the JavaScript, like a browser or Node.
Checking reads your code without running it
Checking is called a static check. "Static" means "without running": TypeScript only reads the text of your code. That's why it can check code that never runs at all:
function couponLabel(percent: number) { return percent.toUpperCase();} console.log("Coupons are off today");Line 1 declares a couponLabel function that takes a percent, a number. Line 2 calls toUpperCase on it, but numbers have no such method; it belongs to text. Line 5 prints a message, and nothing ever calls couponLabel.
TypeScript marks line 2 on purpose: Property 'toUpperCase' does not exist on type 'number'. In plain words: "numbers don't have toUpperCase". A property is a named thing a value has, like a method. Plain JavaScript can't notice this mistake until someone calls the function, maybe weeks later. When the code runs, it prints Coupons are off today, because line 2 never runs.
To check, TypeScript has to know the type of every value. Most of them it works out by reading, which is called inference. Here it works out a type through a whole map call, without running it:
const cart = [ { name: "Tea", price: 3 }, { name: "Cake", price: 4 },];const prices = cart.map((item) => item.price);// ^?console.log(prices.join(", "));Lines 1 to 4 make a cart list with tea and cake. Line 5 uses map to make a new list of just the prices. The // ^? comment on line 6 asks TypeScript what type prices has, and the page shows its answer: const prices: number[]. number[] means "a list of numbers". TypeScript knew each item.price is a number, so the list map gives back must hold numbers. Line 7 prints 3, 4.
Emitting writes plain JavaScript
The second job takes the same file and writes JavaScript. It mostly removes things. Here is a typed function with a type alias, a name for a type made with the type keyword:
type CartItem = { name: string; price: number }; function lineTotal(item: CartItem, quantity: number): number { return item.price * quantity;} const tea: CartItem = { name: "Tea", price: 3 };console.log(lineTotal(tea, 2));Line 1 names a type, CartItem: an object with a name that is text and a price that is a number. Line 3 declares lineTotal, which takes a CartItem and a quantity, and says it gives back a number (the : number after the brackets). Line 4 multiplies. Line 7 makes a tea item, and line 8 prints 6. There are no errors. Here is the cart.js that tsc --target es2022 writes for it:
function lineTotal(item, quantity) {
return item.price * quantity;
}
const tea = { name: "Tea", price: 3 };
console.log(lineTotal(tea, 2));- The
type CartItemline is gone. A type alias is only for TypeScript, so nothing of it is left. - Line 1 has lost
: CartItem,: number, and the: numberafter the brackets. - Line 4 has lost
: CartItem. The object itself is copied as it is. - The empty lines are gone, and the body is indented with four spaces. That's only how
tsclays out its output.
It still prints 6. Removing types like this is called type erasure.
type CartItem line is removed. So are : CartItem and : number. The function itself is copied as it is.The table below shows what happens to each kind of thing in a .ts file. Read each row: this part of your code, what emitting does to it, and what's left.
| In cart.ts | What tsc does with it | What's left in cart.js |
|---|---|---|
: number after a name, like quantity: number | It's removed. Only quantity is left. | function lineTotal(item, quantity) |
A type alias, like type CartItem = { … } | The whole line is removed. | Nothing: the line is gone. |
A value, like "4" or 3 | It's copied exactly as you wrote it. | const cakePrice = "4"; |
A function call, like Number("4") | It's copied. It's JavaScript, so it runs. | const cakePrice = Number("4"); |
Emitting can do one more thing: rewrite new JavaScript as older JavaScript, for old browsers. This is called downleveling (the Transpilers & polyfills lesson explains it). That's what --target picks. Without it, tsc aims at ES5, the edition of JavaScript from 2009, so the overview's cart.js comes out like this:
var teaPrice = 3;
var cakePrice = "4";
console.log(teaPrice + cakePrice);Each const became var, the older way to declare a variable. The program does the same thing and prints 34. The next lesson sets the target once, in a settings file, so you don't type it each time.
Types disappear when the code runs
Because emitting removes every type, no type exists while the code runs. You can see this with JavaScript's typeof, which looks at a value while the code runs and names its kind (the Checking types reliably lesson covers it):
type Price = number; const cakePrice: Price = 4;console.log(typeof cakePrice);console.log(typeof Price);Line 1 names a type, Price, which means a number. Line 3 makes cakePrice a Price and gives it 4. Line 4 prints number: typeof sees the value 4, not the name Price.
Line 5 asks about Price itself, and TypeScript marks it on purpose: 'Price' only refers to a type, but is being used as a value here. In plain words: "Price is only a type, and types don't exist when the code runs". Run it anyway, and line 5 prints undefined, which is what typeof says about a name that doesn't exist. The type Price line was removed, so there's nothing called Price left.
So a type can never change what your code does. The code that runs is the same with or without it. Try it: each button changes line 2. Watch the errors, the line tsc writes, and the output.
const teaPrice: number = 3;const cakePrice: number = "4";console.log(teaPrice + cakePrice);341 error: line 2const cakePrice = "4";The type says number and the value is text, so TypeScript marks line 2: Type 'string' is not assignable to type 'number'. tsc still writes const cakePrice = "4";, and the code prints 34.
The first three buttons give three different types, or none. TypeScript's answer changes each time, but tsc writes the same line, const cakePrice = "4";, so the code prints 34 every time. Only the last button changes the value, to the number 4, and only then does the output change, to 7. A type describes a value. It never turns one kind of value into another.
Errors don't stop the output
You saw it in the overview: tsc reported an error and still wrote cart.js. That's on purpose. The two jobs are separate, and by default an error in one doesn't stop the other. The TypeScript team chose this because you often know better than the checker for a moment, like halfway through turning a JavaScript project into TypeScript.
When you'd rather have no JavaScript than JavaScript with a known mistake, add the --noEmitOnError option. An option (or flag) is a setting you add to a command, starting with --. With this one, any error means tsc writes nothing. The playground also shows each command's exit code: a number a command hands back when it finishes. Build scripts read it: 0 means success, and anything else means something went wrong. Try both prices with both commands:
const teaPrice: number = 3;const cakePrice: number = "4";console.log(teaPrice + cakePrice);writtenFound 1 error in cart.ts:22tsc reports the error on line 2 and still writes cart.js. Exit code 2 means "errors, but the files were written". The page will print 34.
With a number on line 2, there's no error, and both commands write cart.js. With text, plain tsc prints the error, still writes the file, and exits with 2. --noEmitOnError prints the same error, writes nothing, and exits with 1.
cart.ts, with one error. Plain tsc reports it and still writes cart.js. With --noEmitOnError, it reports it and writes nothing.Many tools do only the second job. Build tools like Vite remove the types and never check them, and leave checking to your editor or to a separate tsc run. This page's Edit & run works the same way: it removes the types and runs the code, which is why the overview's example printed 34 even with its error.
Static checks and runtime checks
There are two moments you can check a value. A static check happens before the code runs: TypeScript reads your code. A runtime check happens while the code runs: your own code looks at a real value. They catch different mistakes. Here is a function that needs both:
function readQuantity(text: string) { const quantity = Number(text); if (Number.isNaN(quantity)) { return 1; } return quantity;} const first = readQuantity("2");// ^?console.log(first);console.log(readQuantity("two"));Line 1 declares readQuantity, which takes the text from a quantity box. Line 2 turns it into a number with Number. Line 3 is a runtime check: Number.isNaN asks "is this NaN?". NaN means "not a number", and it's what Number gives back for text like "two". If so, line 4 gives back 1 instead. Otherwise line 6 gives back the quantity.
Line 9 calls it with "2". The query on line 10 shows const first: number: TypeScript worked out that readQuantity always gives back a number. Line 11 prints 2. Line 12 passes "two", and the runtime check makes it print 1. TypeScript couldn't have caught "two": it's text, which is exactly what the function asks for. Only the value is wrong, and values from a shopper only exist while the page runs.
The static check catches a different mistake: a wrong kind of value in your own code.
function readQuantity(text: string) { return Number(text);}console.log(readQuantity(2));Line 4 calls readQuantity(2) with a number. TypeScript marks it on purpose: Argument of type 'number' is not assignable to parameter of type 'string'. An argument is a value you pass in a call, and a parameter is the name that receives it, like text. So: "you passed a number where the function takes text". This happens to print 2 anyway, but the next change to readQuantity might not be so forgiving.
readQuantity(2). While it runs, only your own check, Number.isNaN, can see that a shopper typed two.The table below compares the two kinds of check. Read each row as a question, then each kind's answer.
| Question | Static check (TypeScript) | Runtime check (your code) |
|---|---|---|
| When does it happen? | Before the code runs: while you type, or when you run tsc. | While the code runs, in the browser or in Node. |
| What does it look at? | Your code: names, types, and how they're used. | The real values: what a shopper typed, what a server sent. |
| Who writes it? | Nobody. TypeScript does it from your types. | You do, with code like if (Number.isNaN(quantity)). |
| What does it catch? | A wrong kind of value in your code, a typo, a missing argument. | Bad data from outside: "two" in a quantity box, a price left out. |
| Does it cost anything when the page runs? | No. It's finished before the code runs. | A little: the check is code that runs every time. |
const quantity: number = "2";const quantity = Number("two");Math.max("3", 4);const data = JSON.parse("{ price: 4 }");let price = 4; price.trim();const prices = [3, 4]; prices[5].toFixed(2);const user = { name: "Asha" }; user.nmae;const total: number = JSON.parse('"7"');
Sort each line by who can catch the mistake in it. Each line is checked and run on its own.
A product from a server, checked both ways
A shop page asks a server for a product, and the server replies with text in a format called JSON (the JSON lesson covers JSON.parse). Real code uses both kinds of check. The static check keeps your code consistent. A runtime check guards the door where outside data comes in. Press each button to change what the server sends:
type Product = { name: string; price: number }; function toProduct(data: { name: string; price: string }): Product { const price = Number(data.price); if (Number.isNaN(price)) { throw new Error(`Bad price for ${data.name}`); } return { name: data.name, price };} const reply = '{ "name": "Cake", "price": "4" }';const cake = toProduct(JSON.parse(reply));console.log(cake.price + 3);7No errorspassesThe server sends the price as the text "4". Line 4 turns it into the number 4, the check on line 5 passes, and the code prints 7.
Read the code from the top:
- Line 1 says what a
Productis: a name that is text and a price that is a number. - Line 3 declares
toProduct. It takes the reply's data and promises to give back aProduct. TypeScript checks that promise statically: line 8 must build an object with a text name and a number price. - Lines 4 to 7 are the runtime check. Line 4 turns the price into a number. If that gives
NaN, line 6 throws an error, which stops the code with a message (the try, catch & finally lesson shows how to catch it). - Line 11 is the reply. Line 12 reads it with
JSON.parseand turns it into aProduct. Line 13 prints the price plus 3.
The list under the code says No errors for every reply. TypeScript can't see what a server will send. With "four" or no price at all, only the check on line 5 notices, and the code stops with Error: Bad price for Cake instead of quietly showing a wrong total. After line 12, the rest of your code can trust cake.price to be a real number, and TypeScript checks that it's used like one.
Common mix-ups
A type annotation is a promise you make to TypeScript. It doesn't change the value:
const quantity: number = JSON.parse('"2"');console.log(quantity + 1);Line 1 reads the JSON text "2" with JSON.parse, which gives back the string "2", and says it's a number. JSON.parse can give back anything, so TypeScript believes you and reports nothing. The annotation is removed before the code runs, so quantity is still text, and line 2 prints 21.
- "tsc runs my code." No. It reads your code and writes JavaScript. A browser or Node runs that JavaScript.
- "An error means no JavaScript is written." No. By default,
tscwrites the file anyway. Only--noEmitOnErrorholds it back. - "Writing
: numberturns a value into a number." No. The annotation is removed before the code runs. UseNumber(…)to convert, as the21example shows. - "I can check a type with
typeofwhile the code runs." No.typeofsees values, like 4. A name made withtype, likePrice, is gone by then. - "With TypeScript, I don't need runtime checks." No. TypeScript checks your code, not the data that arrives while it runs. A shopper's text or a server's reply still needs your own check.
- "Every tool that handles .ts files checks the types." No. Many, like Vite and this page's Edit & run, only remove them. Checking comes from your editor or from
tsc.
Practice exercises
Each exercise is TypeScript. Press Edit & run on the starter code to try a change: the errors update as you type, and the console shows what it prints. Guess first, then use the hints.
This is order.ts. How many lines does tsc --target es2022 write to order.js?
type Order = { item: string; quantity: number }; const order: Order = { item: "Tea", quantity: 2 };console.log(order.quantity);type Order = { item: string; quantity: number }; // removed const order: Order = { item: "Tea", quantity: 2 }; // kept, without ": Order"console.log(order.quantity); // kept as it isLine 1 is a type alias, so it's removed whole. Line 3 makes a real object, so it stays without : Order, and line 4 stays as it is. tsc writes 2 lines: const order = { item: "Tea", quantity: 2 }; and console.log(order.quantity);.
TypeScript marks line 4. Run the code anyway: what does it print? Then fix line 4 so the error goes away.
function shippingCost(weight: number) { return weight * 2;}console.log(shippingCost("3"));function shippingCost(weight: number) { return weight * 2;}console.log(shippingCost(3));The starter prints 6: "3" * 2 turns the text into 3 before multiplying. It works by luck. Passing the number 3, as the fix does, removes the error and prints 6 for the right reason.
A shopper typed two in the quantity box. What does the starter print? Then make readQuantity give back 1 when the text isn't a number.
function readQuantity(text: string) { return Number(text);}console.log(readQuantity("two"));function readQuantity(text: string) { const quantity = Number(text); if (Number.isNaN(quantity)) { return 1; } return quantity;}console.log(readQuantity("two"));The starter prints NaN, and TypeScript can't warn you: text goes in and a number comes out. The fix checks the real value while the code runs, and gives back 1 when it isn't a number, so it prints 1.
You run tsc --noEmitOnError cart.ts on this file. Does tsc write cart.js?
const quantity: number = "2";
console.log(quantity);const quantity: number = "2"; // an error, so --noEmitOnError writes no cart.jsconsole.log(quantity);Line 1 puts the text "2" where a number goes, so there is an error: Type 'string' is not assignable to type 'number'. With --noEmitOnError, tsc prints it and writes no cart.js. Without the option, it would write the file anyway.
Make the error go away by changing only the type on line 1. What does the code print now?
const cakePrice: number = "4";console.log(3 + cakePrice);const cakePrice: string = "4";console.log(3 + cakePrice);: string matches the text "4", so the error is gone. tsc writes the same JavaScript as before, const cakePrice = "4";, so it still prints 34. A type changes what TypeScript says, never what the code does. To get 7, you'd have to change the value.
The server sent a bad price, and the starter quietly prints NaN. Add a runtime check so it prints Bad price instead. What does your fixed code print?
type Product = { name: string; price: number }; const reply = '{ "name": "Cake", "price": "four" }';const cake: Product = JSON.parse(reply);console.log(cake.price * 2);type Product = { name: string; price: number }; const reply = '{ "name": "Cake", "price": "four" }';const data = JSON.parse(reply);const price = Number(data.price);if (Number.isNaN(price)) { console.log("Bad price");} else { const cake: Product = { name: String(data.name), price }; console.log(cake.price * 2);}The fix checks the price where it arrives. Number("four") is NaN, so the code prints Bad price instead of a wrong number. When the price is good, it builds a real Product and prints double the price.
Check your understanding
For each question, keep the moments apart: what tsc says and writes before the code runs, and what the code does when it runs. The quiz shows its code without the checker, so the error list doesn't give the answers away.
Question 1 of 7What are the two jobs
tscdoes with a .ts file?Choose an answer to see the explanation.
Question 2 of 7What does
typeof teaprint in this code?Read the code, then predicttype Quantity = number; const tea: Quantity = 2; console.log(typeof tea);Choose an answer to see the explanation.
Question 3 of 7
tscreports one error in cart.ts. With no other options, what happens to cart.js?Choose an answer to see the explanation.
Question 4 of 7How many lines does
tsc --target es2022write for this file?Read the code, then predicttype User = { name: string }; const user: User = { name: "Asha" }; console.log(user.name);Choose an answer to see the explanation.
Question 5 of 7This function is never called. Does TypeScript still report line 2?
Read the code, then predictfunction greet(name: string) { return name * 2; } console.log("Hi");Choose an answer to see the explanation.
Question 6 of 7Which of these can only be checked while the page runs?
Choose an answer to see the explanation.
Question 7 of 7TypeScript reports no errors here. What does the code print?
Read the code, then predictfunction readQuantity(text: string): number { return Number(text); } console.log(readQuantity("two"));Choose an answer to see the explanation.
Key takeaways
- The TypeScript compiler,
tsc, does two jobs: checking reports type errors, and emitting writes plain JavaScript. - Checking is static: it reads your code without running it, so it checks every line, even code that never runs.
- Emitting removes the types: annotations, type aliases, and the rest. Everything else is copied, so the code does exactly what it did.
- No type exists while the code runs.
typeofsees values, and an annotation never converts a value. - By default, an error doesn't stop
tscfrom writing JavaScript.--noEmitOnErrorchanges that. - Static checks catch mistakes in your code. Runtime checks, written by you, catch bad data that arrives while the code runs.
Remember the one-liner.
tsc checks your types, then removes them, and what runs is the JavaScript that's left.
Coming next: Setting up a project. You'll install tsc, write a tsconfig.json settings file, and run TypeScript in your editor and in Node.