In this chapter we will…
- Get hands-on with our first TypeScript program and the compiler CLI command
- Learn how the compiler-emitted JS code changes depending on JS language level and module type
- Examine a simple program’s compiled output, including the type declaration file
Anatomy of the project
Let’s consider a very simple TypeScript project
that consists of only three files:
sh
package.json # Package manifesttsconfig.json # TypeScript compiler settingssrc/index.ts # "the program"
This project can be found in the packages/welcome-to-ts
folder, within the workshop repo
sh
cd packages/welcome-to-ts
package.json
(view source)
jsonc
{"name": "welcome-to-ts","license": "NOLICENSE","devDependencies": {"typescript": "^5.2.0"},"scripts": {"dev": "tsc --watch --preserveWatchOutput"}}
Note that…
- We just have one dependency in our package.json:
typescript
. -
We have a
dev
script (this is what runs when you invokeyarn dev-welcome-to-ts
from the project root)- It runs the TypeScript compiler in “watch” mode (watches for source changes, and rebuilds automatically).
The following is just about the simplest possible config file for the TS compiler:
tsconfig.json
(view source)
jsonc
{"compilerOptions": {"outDir": "dist", // where to put the TS files"target": "ES2015", // JS language level (as a build target)"moduleResolution": "Node" // Find cjs modules in node_modules},"include": ["src"] // which files to compile}
All of these things could be specified on the command line (e.g., tsc --outDir dist
), but particularly as
things get increasingly complicated, we’ll benefit a lot from having this config file:
Finally, we have a relatively simple and pointless TypeScript program. It does
have a few interesting things in it that should make changes to the "target"
property in our tsconfig.json
more obvious:
- Use of a built in
Promise
constructor (introduced in ES2015) - Use of
async
andawait
(introduced in ES2017) - Use of a
static
private class field (introduced in ES2022)
Here is the original (TypeScript) source code that we aim to compile:
src/index.ts
(view source)
tsTry
/*** Create a promise that resolves after some time* @param n number of milliseconds before promise resolves*/functiontimeout (n : number) {return newPromise ((res ) =>setTimeout (res ,n ))}/*** Add two numbers* @param a first number* @param b second*/export async functionaddNumbers (a : number,b : number) {awaittimeout (500)returna +b }classFoo {static #bar = 3staticgetValue () {returnFoo .#bar}}//== Run the program ==//;(async () => {console .log (awaitaddNumbers (Foo .getValue (), 4))})()
Note that when you hover over certain code points on this website, you get the equivalent of a “VScode tooltip”. This is one of our most important tools for learning about how TypeScript understands our code!
Running the compiler
From within the packages/welcome-to-ts
folder of the git repo, you can run
sh
tsc
to build the project. Alternatively, from within the same folder, you can run this command to start a task that will rebuild the project whenever you change important files.
sh
yarn dev
You should see something in your terminal like:
12:01:57 PM - Starting compilation in watch mode...
Note that within the “welcome-to-ts” project
- a
./dist
folder has appeared, - inside it is an
index.js
file.
Open this file — it will be a mess
Click here to see what the compiled output looks like
jsTry
var__awaiter = (this && this.__awaiter ) || function (thisArg ,_arguments ,P ,generator ) {functionadopt (value ) { returnvalue instanceofP ?value : newP (function (resolve ) {resolve (value ); }); }return new (P || (P =Promise ))(function (resolve ,reject ) {functionfulfilled (value ) { try {step (generator .next (value )); } catch (e ) {reject (e ); } }functionrejected (value ) { try {step (generator ["throw"](value )); } catch (e ) {reject (e ); } }functionstep (result ) {result .done ?resolve (result .value ) :adopt (result .value ).then (fulfilled ,rejected ); }step ((generator =generator .apply (thisArg ,_arguments || [])).next ());});};var__classPrivateFieldGet = (this && this.__classPrivateFieldGet ) || function (receiver ,state ,kind ,f ) {if (kind === "a" && !f ) throw newTypeError ("Private accessor was defined without a getter");if (typeofstate === "function" ?receiver !==state || !f : !state .has (receiver )) throw newTypeError ("Cannot read private member from an object whose class did not declare it");returnkind === "m" ?f :kind === "a" ?f .call (receiver ) :f ?f .value :state .get (receiver );};var_a ,_Foo_bar ;/*** Create a promise that resolves after some time* @param n number of milliseconds before promise resolves*/functiontimeout (n ) {return newPromise ((res ) =>setTimeout (res ,n ));}/*** Add two numbers* @param a first number* @param b second*/export functionaddNumbers (a ,b ) {return__awaiter (this, void 0, void 0, function* () {yieldtimeout (500);returna +b ;});}classFoo {staticgetValue () { return__classPrivateFieldGet (_a ,_a , "f",_Foo_bar ); }}_a =Foo ;_Foo_bar = {value : 3 };//== Run the program ==//(() =>__awaiter (void 0, void 0, void 0, function* () {console .log (yieldaddNumbers (Foo .getValue (), 4));}))();
If you’re familiar with the very old ES5
JavaScript language level, there are a few things in here that tell us we’re dealing with ES2015
. For example, the use of the yield
keyword, a generator function*
, class
es and a Promise
constructor.
Changing target language level
If we go to welcome-to-ts/tsconfig.json
and change the “compilerOptions.target” property:
diff
{"compilerOptions": {"outDir": "dist",- "target": "ES2015"+ "target": "ES2017"},"include": ["src"]}
Look at that dist/index.js
file again — it’s much cleaner now! Do you notice what has changed?
Click here to see what the compiled output looks like
tsTry
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);};var _a, _Foo_bar;/*** Create a promise that resolves after some time* @param n number of milliseconds before promise resolves*/function timeout(n) {return new Promise((res) => setTimeout(res, n));}/*** Add two numbers* @param a first number* @param b second*/export async function addNumbers(a, b) {await timeout(500);return a + b;}class Foo {static getValue() {return __classPrivateFieldGet(_a, _a, "f", _Foo_bar);}}_a = Foo;_Foo_bar = { value: 3 };//== Run the program ==//;(async () => {console.log(await addNumbers(Foo.getValue(), 4));})();
Some changes to observe:
- We start to see
async
andawait
- We no longer see the
_awaiter
helper
It’s starting to look more recognizable — as if the type information has just been stripped away from our original .ts
source code in some places. There’s still some compiler-provided workarounds for the private class field, which isn’t supported in native JavaScript until ES2022.
And now finally, let’s try ES2022:
diff
{"compilerOptions": {"outDir": "dist",- "target": "ES2017"+ "target": "ES2022"},"include": ["src"]}
Click here to see what the compiled output looks like
tsTry
/*** Create a promise that resolves after some time* @param n number of milliseconds before promise resolves*/function timeout(n) {return new Promise((res) => setTimeout(res, n));}/*** Add two numbers* @param a first number* @param b second*/export async function addNumbers(a, b) {await timeout(500);return a + b;}class Foo {static #bar = 3;static getValue() {return Foo.#bar;}}//== Run the program ==//;(async () => {console.log(await addNumbers(Foo.getValue(), 4));})();
Declaration Files
You may also notice that a .d.ts
file is generated as part of the compile process. This is known as a declaration file.
tsTry
/*** Add two numbers* @param a first number* @param b second*/export declare function addNumbers(a: number, b: number): Promise<number>;
A good way to think of TS files:
.ts
files contain both type information and code that runs.js
files contain only code that runs.d.ts
files contain only type information
There are other types of file extensions which have a TypeScript equivalent
File Purpose | JS extension | TS extension |
---|---|---|
React | .jsx |
.tsx |
Native ES Modules | .mjs |
.mts |
Types of modules
CommonJS
Did you notice that the export
keyword was still present in the build output for our program? We are generating ES2015 modules from our TypeScript source.
If you tried to run this file with node
like this:
sh
node packages/welcome-to-ts/dist/index.js
There’s an error!
sh
export async function addNumbers(a, b) {^^^^^^SyntaxError: Unexpected token 'export'
It seems that, at least with most recent versions of Node.js and the way our project is currently set up, we can’t just run this program directly as-is.
Node conventionally expects CommonJS modules 1, so we’ll have to tell TypeScript to output this kind of code.
Let’s add a new property to our tsconfig
file:
diff
"compilerOptions": {"outDir": "dist",+ "module": "CommonJS",
Look at your packages/welcome-to-ts/dist/index.js
one more time now. You should see
that the way the addNumbers
function is exported has changed:
jsTry
exports .addNumbers =addNumbers
This is an indication that we’re emitting CommonJS modules! We could try running
this program with node
one more time:
sh
node packages/welcome-to-ts/dist/index.js
If the program works correctly at this point, we should see it pause for a short
time and then print 7
to the console, before ending successfully.
ES Modules
Node now supports running native modules (.ejs
files) directly! We can configure TypeScript to build this type of file. Make this change to your tsconfig.json
diff
"target": "ES2022",+ "module": "NodeNext",- "module": "CommonJS",- "moduleResolution": "Node"
Build the file again by running tsc
while in the ./packages/welcome-to-ts
folder.
Finally, run from within the same folder
sh
node dist/index.js
And you should see 7
printed to the console again!
CONGRATS! You’ve just compiled your first TypeScript program!