Table of Contents
Open Table of Contents
Introduction
With the rise of TypeScript for both Frontend and Backend development, many developers are looking to add types to their JavaScript code. Too often, software developers have to dive into existing, complex codebases and hit roadblocks because they did not take a minute to understand the fundamental of the language and ecosystem. This hands-on guide will show you how to get started with TypeScript in a Node.js project, and, hopefully, steer you around some of the common pitfalls.
TypeScript is a superset of JavaScript, which means that all JavaScript code is valid TypeScript code. TypeScript adds static types, classes, and interfaces to JavaScript.
Types assist in the following:
- Catch errors at compile time
- Provide better documentation
- Allow for better refactoring
- Enable better tooling
- Increase developer productivity and coding confidence
In this lab, we will start with JavaScript then move into TypeScript.
PROTIP 😎 Please avoid the temptation to copy/paste your way through this lab. You will learn more by typing the code and commands yourself!
WARNING ⚠ Types are only checked at compile time. This means that you can still have runtime errors!
Prerequisites
- A text editor or IDE such as VSCode, Cursor, or WebStorm and working knowledge of the editor/IDE.
- A Linux-based terminal and working knowledge of the terminal.
- For Windows users, WSL2 is a good option.
- Git Bash may also work.
- Using the terminal eliminates a lot of instructional ambiguity.
- Node.js installed
Start with JavaScript
Step 0: Create a nodejs project
# change to home directory
cd ~
# create a project directory
mkdir typescript-nodejs-lab
# change into the project directory
cd typescript-nodejs-lab
# create a package.json file
npm init -y
Open ~/typescript-nodejs-lab
in your text editor or IDE. You should see only a package.json
file
in the project directory.
Step 1: Create and Run a JavaScript File
For this step, we will create a simple JavaScript file that emits a message to the console and learn to run it via Node.js a few different ways.
Create a file named hello.js
and add the following code:
// emit a message to the console, AKA stdout
console.log("Hello, world!");
Run it explicitly with node:
node hello.js
Run it via npm:
In package.json, add a script to run the hello.js file by adding
{
"name": "typescript-nodejs-lab",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"say-hello": "node hello.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": ""
}
Using npm scripts is a great way to organize your project. Effectively, you are defining short aliases for long commands
such as ENVIRONMENT=development node do-something.js && dosomethingelse
.
For frequently used commands, you may save more typing and time by making your scripts executable. Try the following:
Create a new directory and file in the project:
mkdir scripts
touch scripts/do-something.js
Add the following code to scripts/do-something.js
:
#!/usr/bin/env node
console.log("Doing something...");
Now for some more terminal commands:
# Make the script executable
chmod +x scripts/do-something.js
# Run the script
./scripts/do-something.js
# Add `./scripts` to the PATH:
# NOTE: to make this permanent, add the line to your ~/.bashrc or ~/.zshrc file
export PATH="$PATH:$(pwd)/scripts"
# Run the script again
do-something.js
# Drop the file extension
mv scripts/do-something.js scripts/do-something
# Run the script again
do-something
# It still works!
Larger project generally benefit from have a handy scripts directory to take care of repetitive tasks.
You may consider using this technique to avoid cluttering up project.json
. Or, if you’d rather not
have to type npm run
.
Code reuse
Code reuse is a key concept in software development. In Node.js, code reuse is achieved through modules. Modules are a way to organize code into reusable units. They are a fundamental concept in Node.js and are often a source of confusion for new developers. That confusion is compounded by the fact that there are two different module systems in Node.js: CommonJS and ES Modules.
Modules with CommonJS
When JavaScript expanded beyond the web browser, there was no standard module system. As a result, CommonJS was invented with Node.js.
Let’s try it out. Create a new directory and file in the project:
mkdir lib
touch lib/messages.cjs
Add the following code to lib/messages.cjs
:
module.exports = {
message1: "Hello",
message2: "World",
echo(message) {
console.log(message);
}
}
Rename hello.js
to hello.cjs
and modify it to use the messages
module:
const message = require("./lib/messages.cjs");
message.echo(message.message1);
message.echo(message.message2);
Run the hello.cjs
file, and you should see the messages in the console. Using this technique,
you can define variables and functions in one place and reference it from one or more other places.
module.exports
is a property of the module
object. It is used to export values from the module,
which you can then import into other files via require
.
The .cjs
extension is a convention for CommonJS modules. It is not required, but it is a good idea
to use it to avoid confusion and ensure compatibility, especially in a project that uses both CommonJS
and ES Modules.
Modules with ES Modules
Ecmascript version 6 (AKA ES6, AKA ES2015) introduced the ES Modules module system (aka ESM). ESM is now more broadly supported across the JavaScript ecosystem, including the web browser, Bun, and Deno.
Let’s try it out. Create a new directory and file in the project:
touch lib/messages.mjs
Add the following code to lib/messages.mjs
:
export const message1 = "Hello";
export const message2 = "World";
export default function echo(message) {
console.log(`echo: ${message}`);
}
export function echo2(message) {
console.log(`echo2: ${message}`);
}
Create a new file and import the module:
touch hello.mjs
Add the following code to hello.mjs
:
import whatever, { message1, message2 as altMessage, echo2 } from "./lib/messages.mjs";
import * as messages from "./lib/messages.mjs";
whatever(message1);
whatever(altMessage);
echo2(message1);
echo2(altMessage);
messages.echo2(message1);
messages.echo2(altMessage);
messages.default(message1);
Run the hello.mjs
file and review the output.
ESM supports named and default exports. To demonstrate, we tried importing in a few different way here.
- The default export is imported as
whatever
and can be named anything. - The named exports,
message1
andecho2
, are imported “as is”. - The named export
message2
is imported asaltMessage
. This technique is useful to avoid naming conflicts. - The
*
syntax imports all named exports as an object. This technique is useful to avoid naming conflicts or if you simply want to import all of the exports from a module and access them via the object.
What about TypeScript?
Oh wait, we haven’t talked about TypeScript yet! Let’s fix that.
TypeScript support in Node.js is currently experimental and does not perform any type checking. For that you need to use tsc
(TypeScript Compiler). Additionally, we’ll use vite-node
to
more easily run TypeScript code directly.
Installing dependencies
Let’s take modules to the next level by installed some from the NPM registry.
npm install -D typescript vite-node
npm install -S date-fns
Once the npm install
is complete, take a look at the package.json
file. You will find
some new keys: devDependencies
and dependencies
related to the items just installed. Dependencies
used for build and linting are typically listed here. Runtime dependencies, modules that
need to be available when the application is running, would be listed in the dependencies
key.
Now look at the newly created node_modules
directory. You will see the NPM modules we just installed
and their dependencies. The default registry for Node.js modules is https://www.npmjs.com/.
Also make note of the new package-lock.json
file. This file is used to lock in the exact versions of
the installed modules, which is important for reproducibility and consistency. You should commit this
file to your code repository and rarely should you ever need to manually edit it. Usually, you would NOT
commit the node_modules
directory to your code repository, since it can get quite large and effectively a duplicate
what is already open source.
Create a TypeScript project
Run this command to quickly create a tsconfig.json
file
in the project directory. This file is used to configure the TypeScript compiler.
npx tsc --init
PROTIP 😎 tsconfig.json
can be a major contributor to developer frustration. Matt Pocock has a great
reference article on the subject: https://www.totaltypescript.com/tsconfig-cheat-sheet.
Edit the tsconfig.json
file and add a outDir
property to the compilerOptions
object at the top
of the file so that it looks like this:
{
"compilerOptions": {
"outDir": "./dist",
// other stuff
Create a TypeScript file to use the date-fns
module, transpile it to JavaScript,
and run it.
touch hello.ts
Add the following code to hello.ts
:
import {format} from "date-fns/format";
console.log("Today's date is " + format(new Date(), "yyyy-MM-dd"));
Now, transpile the TypeScript code to JavaScript into the dist
directory and run it:
# Transpile the TypeScript code to JavaScript into the dist directory
npx tsc
## Run the new js file
node dist/hello.js
Take a look at the dist
directory. You will see the hello.js
file, which is
original ts file, transpiled to JavaScript in a way that is compatible with Node.js.
Now try running the .ts
file directly with vite-node
:
npx vite-node hello.ts
Yeah, that’s much easier to iterate with. We’ll still need to check for typing errors, though.
Your editor should be able to help with that. But, when you’re ready to deploy, you should
perform a more thorough type check with tsc
.
Let’s add some npm scripts to help. Remember: this goes into the scripts
object in package.json
.
{
"scripts": {
"check-types": "tsc --noEmit",
"start": "vite-node dist/hello.js"
}
}
Now try:
## Check for typing errors without emitting js output
npm run check-types
# Run the ts file directly using vite-node
npm run start
# ...or this works, too, start is automatically recognized as a script by npm
npm start
PROTIP 😎 npm run
is short for npm run-script
. npm r
works, too. You just have include
enough character to identity the command.
Types and Inference
Finally, let’s put TypeScript to work.
Edit the hello.ts
file to the following:
let value = 1;
value = "2";
console.log(value);
Then run the type check:
npm run check-types
You should see an error like this:
Type 'string' is not assignable to type 'number'.
This is the TypeScript compiler catching an error for us. Is this value supposed to be a string or number?
Let’s talk more about what is happening here.
Union types
Just because we’re using TypeScript doesn’t mean we have to explicitly declare types on every variable.
TypeScript can infer the type of a variable based on the value it is assigned. In the editor,
hover over the value
variable and you should see the type inferred as number
. Try changing
the value to true
or a string. What happens?
Now, what if we actually want to enable both types? We can do that with a union type.
Edit the hello.ts
file to the following:
let value: number | string = 1;
value = "2";
console.log(value);
Check types again to see if tsc is happy.
Narrowing
Let’s look at one more concept and call it “good” for now: Narrowing.
Narrowing is the process of narrowing the type of a variable based on the value it is assigned. This
is a very important concept since it helps to avoid dreaded, archaic errors such as undefined is not a function
.
Edit the hello.ts
file to the following:
// declare a Person type
interface Person {
/**
* The person's name
*/
name: string;
/**
* The person's email address
*/
email: string;
/**
* The person's age
* This value is make optional via the `?` operator, meaning it can
* be undefined
*/
age?: number;
}
const person: Person = {
name: "John",
email: "[email protected]"
};
console.log(person.age.toFixed(2));
Check types again to confirm that tsc is NOT happy.
'person.age' is possibly 'undefined'.
Try running the file via npm start
.
TypeError:Cannot read properties of undefined (reading 'toFixed')
Sure enough, you get a dreadful error. This is what your customer could see. 😭
What is going on here? The compiler knows that person.age
is either undefined
or number
.
When it is undefined
, the length
property is not available. To fix this, we need to narrow
the type of person.age
to a number.
We can do this with a type check.
if(typeof person.age === "number") {
console.log(person.age.toFixed(2));
} else {
console.log("Age is not a number");
}
Depending on the scenario, used of the optional chaining operator (?.
) may be acceptable, too.
// this could be ok
console.log(person.age?.toFixed(2));
Escape hatches are available, too, that allow you to bypass the type checker in different ways. For the most part, these features are unsafe, a sign of code smell, and you should avoid them, rethink your design, and maybe ask your favorite LLM to help you figure out a better solution.
// assert that person.age will never be undefined
// will cause a runtime error if this is a lie
console.log(person.age!.toFixed(2));
// cast to a number
// also a lie
console.log((person.age as number).toFixed(2));
// cast to any
// another lie!
console.log((person.age as any).toFixed(2));
Wrapping Up
Hopefully, this lab has provided a decent foundation for beginning to build a TypeScript project with Node.js. If you are looking for more, please check out the following resources:
- TypeScript Handbook
- TypeScript Deep Dive
- Total TypeScript Matt Pocock also publishes content on YouTube, Twitter-x, his blog, and probably more.
Thanks for reading!