Introduction
Since we’re talking about the future of JavaScript, this series of articles wouldn’t be complete if I didn’t talk about TypeScript. Created by Microsoft in 2012, TypeScript is a superset of JavaScript that adds static typing to the language. JavaScript doesn’t have the best reputation when it comes to types. In fact, it’s often cited as one of the worst parts of the language. This is because it is a dynamically typed language.
Dynamic Typing
That’s a big regret because that breaks an important mathematical property— the equivalence relation property. It was not totally unknown. Other languages had similar warts. —Brendan Eich on Dynamic Typing
I have heard it said before that JavaScript does not have types. This is not true. The problem is that JavaScript is a dynamic, weakly typed language. This means that the type of a variable is not known until it is actually executed, and it can be changed at any time. This is in contrast to most statically typed languages where the type of each variable is defined as soon as the code is compiled and cannot be changed. This can lead to some unexpected behavior.
let x = 1; // x is a number
x = "hello" // x is now a string.
// This is perfectly valid JavaScript.
Some consider this a feature and not a bug. It allows for a lot of flexibility in the language. However, many developers prefer the guard rails that static typing provides. It allows for better tooling, and it helps catch bugs before they happen. So, people have been trying to add static typing to JavaScript for a long time.
Static Typing
Detecting errors in code without running it is referred to as static checking. Determining what’s an error and what’s not based on the kinds of values being operated on is known as static type checking. —TypeScript for the New Programmer
Static typing allows the developer to define the data structure of each variable in the code so that it can be interpreted by a compiler. This is done by adding a type annotation to the variable with a special syntax that tells the compiler what type of data it is. The compiler then checks to make sure that the variable is only used in ways that are consistent with that type, and it will throw an error if it is not.
JavaScript is not usually compiled, so any errors that are caused by using a variable incorrectly will not be caught until runtime. This means that the error will not be thrown until the code is ran— possibly by the end user. Rather than waiting for users to have bad experiences, static typing allows the compiler to catch errors before the code is executed. Here’s an example of how this works in TypeScript.
let x: number = 1; // x is a number
x.toUpperCase(); // This will cause a compilation error.
// Normally, this could cause issues for our users at runtime.
A simple example, but it shows how static typing can help catch bugs before they happen. It also allows for better tooling. For example, if you are using an IDE that supports TypeScript, it will be able to provide you with better auto-complete suggestions because it knows the type of each variable. Or, if it’s a function, it will be able to tell you what type of data it expects as an argument and what type of data it will return.
JSDoc
It is a common development problem: you have written JavaScript code that is to be used by others and need a nice-looking HTML documentation of its API. The de facto standard tool in the JavaScript world for generating API documentation is JSDoc. —Dr. Axel Rauschmayer Exploring JS
Before TypeScript was created, there were a few different ways to add static typing to JavaScript. While it wasn’t explicitly created for this purpose, one of the most popular methods is using JSDoc. JSDoc is a documentation generator for JavaScript. Inspired by JavaDoc, it uses a special comment syntax to add formatted annotations to JavaScript code.
While it does not provide a type-checking compiler, it can be used by other tools to provide static typing. For example, Google Closure Compiler uses JSDoc, and you can turn on a setting in IDE’s like VSCode to check your code for errors using JSDoc. Here’s an example of how you can use it to add types to a function.
/**
* Adds two numbers together. I bet you need an example.
* @param {number} x - The first number.
* @param {number} y - The second number.
* @return {number}
* @example
* add(1, 2) // 3
*/
function add(x, y) {
return x + y;
}
It’s a bit bulky and hard to read, but it also does a few things that TypeScript can’t. I really enjoy the ability to add descriptions to each parameter and return value. Now, if you put this code in an IDE that supports it, you can hover over the function and see the descriptions. I even included an example of how to use the function. This is a great way to document your code so that it can be understood by others.
TypeScript
TypeScript allows us to specify what types of values may be provided for parameters and variables… By restricting our code to only being able to be used in the ways you specify, TypeScript can give you confidence that changes in one area of code won’t break other areas of code that use it. —Josh Goldberg, Learning TypeScript
So, like I said, TypeScript is a superset of JavaScript. This just means that it adds some extra features to JavaScript to help make it more like a statically typed language. If you use .ts
or tsx
files and a special syntax, you can use the TypeScript compiler to check the code for errors outside of the context of an IDE. Browsers can’t interpret this syntax though, so the compiler will convert it to regular JavaScript. Here’s another example.
function add(x: number, y: number): number {
return x + y;
}
Because JSDoc is just a documentation generator, it can’t actually check the code for errors by itself. So, while you can add it to your CI/CD pipeline, it won’t break the build if there are errors. But, you can still use it in conjunction with TypeScript to annotate your code. TypeScript doesn’t have a good way to add descriptions for your functions and variables. Here’s one way to get the best of both worlds.
/**
* Adds two numbers together. I bet you need an example.
* @param x The first number
* @param y The second number
* @returns The sum of the two numbers
* @example
* add(1, 2) // 3
*/
function add(x: number, y: number): number {
return x + y;
}
Alternatively, one can also use the TypeScript compiler to check your JSDoc annotated code for errors without using TypeScript syntax at all. This has been recently championed by such tech luminaries as Rich Harris who has gone so far as to convert the entire Svelte code base to JSDoc TypeScript.
The basic idea is that you get most of the benefits of TypeScript without having to go through the hassle of a compilation step. Microsoft has full support for type checking JSDoc annotations. They maintain TSDOC as well which is focused strictly on documenting TypeScript code.
Adding Types to our App
The most common kinds of errors that programmers write can be described as type errors: a certain kind of value was used where a different kind of value was expected. This could be due to simple typos, a failure to understand the API surface of a library, incorrect assumptions about runtime behavior, or other errors. —The TypeScript Handbook
Now that we have a basic understanding of TypeScript, let’s add it to our app. We will start by installing the TypeScript compiler. Twenty or so chapters later, and we’re finally adding a second dependency to our app. Don’t worry, it’s not that bad. We just need to install it as a dev dependency.
npm install --save-dev typescript
From this point on, this series of articles will branch into two paths. On the first path, we will fully embrace the build step and use .ts files that will be compiled to .js files. In the next chapter, this version of the app will be bundled and minified for production. On the second path, we will use JSDoc annotations, and explore how far we can go without compilation. Either way, we’ll need to configure TypeScript to work with our app.
The tsconfig.json File
The presence of a tsconfig.json file in a directory indicates that the directory is the root of a TypeScript project. The tsconfig.json file specifies the root files and the compiler options required to compile the project. —What is a tsconfig.json
So, we’ll start with the compiled version. First, we need to create a tsconfig.json
file. This tells the compiler exactly how to compile our code. There are a surprising amount of options, but we’ll just use a few important ones for now.
tsconfig.json
{
"compilerOptions": {
"target": "ESNext", /* How the code is compiled. We're using the latest version of ECMAScript. */
"lib": ["ESNext", "DOM", "DOM.Iterable"], /* The libraries that are available to the compiler. */
"module": "ESNext", /* Tells the compiler to use ECMAScript modules if possible. */
"moduleResolution": "NodeNext" /* How the compiler "resolves" (finds) modules. */,
"outDir": "./public" /* Where JavaScript files are placed after compilation. */,
"noEmitOnError": true /* Disable emitting files if any type checking errors are reported. */,
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
"strict": true /* Enable all strict type-checking options. Why not? We're using TypeScript. */,
},
"include": ["src"] /* Specify files to include here. */,
"exclude": ["node_modules", "public"] /* Specify files to exclude here. */
}
Other than that, it’s just a matter of a tightening up the code base to make the compiler happy. Much of the work can be accomplished by simply defining the types of all of our variables and functions. Rather than repeating these definitions, it can be helpful to create a central type definition file for any of our custom types that we want to use throughout the app. We’ll call it index.d.ts
. This lets us define our types once and use them anywhere in our app.
index.d.ts
interface FancyButton extends HTMLButtonElement {
alreadyAdded: boolean
id: string
}
interface Product {
id: number
title: string
description: string
price: number
discountPercentage: number
rating: number
stock: number
brand: string
category: string
thumbnail: string
images: string[]
}
interface CartItem {
quantity: number
product: Product
}
interface Cart {
[key: number]: CartItem
}
interface CSSStyleDeclaration {
viewTransitionName: string
}
// now included in @definitelytyped/dom-view-transitions package
Now, we can use these types throughout our app. For example, the buttonFinderAdd
function can be a little tricky to type if you don’t know what you’re doing. But, since we extended the HTMLButtonElement
interface, we can use the FancyButton
type without any trouble. Here’s what my final version of the function looks like.
render.ts
export async function buttonFinderAdd() {
const products = await getProducts()
const buttons: FancyButton[] = Array.from(
document.querySelectorAll(".add-to-cart")
)
buttons.forEach((button) => {
if (button.alreadyAdded) return
button.alreadyAdded = true
button.addEventListener("click", (event) => {
const target = event.target as FancyButton
const id = target.id
const product = products.find(
(product: Product) => product.id === Number(id)
)
if (!product) {
Nope("badID", id)
return
}
addToCart(product)
})
})
}
I would like to draw your attention to the event.target as FancyButton
part. This is called a type assertion. It’s a way of telling the compiler that you know more about the type of a variable than it does. In this case, we know that the event.target
(the thing the user clicked) is a button with the extra property alreadyAdded
, but the compiler doesn’t. The compiler can’t know everything, and sometimes you have to help it out a bit. If we tried to use event.target.alreadyAdded
without the type assertion, the compiler would throw an error.
Generally, you want to keep assertions to a minimum. While they are necessary in situations where the compiler returns any or unknown, their use equates to a loss of actual type checking. This can lead to bugs. Another way of doing this without an assertion is by using the in
operator. This is a way of checking if a property exists on an object. It’s safer because the in
operator actually exists in JavaScript, and it does a runtime check on the object. Here’s what that would look like.
function isFancyButton(button: HTMLButtonElement): button is FancyButton {
return "alreadyAdded" in button
}
// ... later
button.addEventListener('click', (e) => {
const target = e.target;
if (!target || !isFancyButton(target)) return;
// TypeScript now knows that target is a FancyButton
// and we can use target.alreadyAdded without an assertion.
})
If you want to see more of the conversion process, you can watch the video above or just check out the code below for yourself. Otherwise, let’s move on to JSDoc.
2-typescript
JSDoc TypeScript
There’s all of these points of friction that get added when you use a non-standard language like TypeScript that I have come to believe makes it not worth it. So, instead we put our types in JSDoc annotations and we get all of the type safety but none of the drawbacks because it’s just JavaScript. Everything is in comments so you can just run the code. —Rich Harris, Dev Vlog: April 2003
The JSDoc path is a bit more complicated, but it’s also more interesting. We still install TypeScript as a dev dependency, but our tsconfig.json
is going to be a little different this time.
tsconfig.json
{
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"], /* The libraries that are available to the compiler. */
"moduleResolution": "nodenext", /* How the compiler "resolves" (finds) modules. */,
"module": "esnext", /* Tells the compiler to use ECMAScript modules if possible. */
"allowJs": true, /* Allow JavaScript files to be compiled. */
"checkJs": true, /* Enable error reporting in JavaScript files. */
"noEmit": true, /* Disable emitting files. We're not compiling, so we don't need this. */
"strict": true /* Enable all strict type-checking options. May as well if we're using TypeScript. */,
},
"include": ["public/**/*.js", "public/**/*.d.ts"],
"exclude": ["node_modules"]
}
With these settings, we’re not really compiling anything anymore. We’re just using TypeScript to check our JavaScript files for errors. But, we can no longer use TypeScript syntax because it is not understood by browsers.
We can still define all of our types centrally though. Most of the articles that I read about this recommended creating a central .js
file through which we can import and export all of our types. This is fine, but I don’t like how it requires us to import the types into every single file.
Personally, the method that I like the best is still using a central .d.ts
“barrel file” to define all of our types, but you can have multiple of these if you don’t want everything in one file. I see people saying that you still need to import the types into each .js file, but everything just works for me without doing that in VSCode. When I run the compiler, it still checks the types even though I don’t import them.
I’m not sure if this is a new feature or what, but I’m not complaining. The StackBlitz demos that I’ve created for this series don’t seem to work this way though, so you’re not going to get this convenience in every environment. Both the types.js and index.d.ts files are in the repo if you want to try it out for yourself.
So, we can still use all of the same TypeScript definitions in our index.d.ts
, but we’ll need to use JSDoc annotations in each component since the browser can’t handle TypeScript syntax. Here’s what our buttonFinderAdd
function looks like with JSDoc annotations. I will show a variety of ways to apply our custom FancyButton
type.
render.ts
import * as Types from "../types.js"
// ... other code
/**
* Finds all the add-to-cart buttons and adds an event listener to them.
* @returns {Promise<void>} - A promise that resolves when the buttons have each been given an event listener.
* @async
*/
export async function buttonFinderAdd() {
const products = await getProducts()
// import * as Types from "../types.js"
// this brings in all the types so we can do this:
// /** @type {NodeListOf<Types.FancyButton>} */
// Or, we can import one type at a time inlined with the JSDoc annotation.
// /** @type {import("../types").FancyButton}*/
// Or, we can add the property on the fly.
// /** @type {NodeListOf<HTMLButtonElement & { alreadyAdded: boolean }>} */
// I like writing custom interfaces in the index.d.ts file.
/** @type {NodeListOf<FancyButton>} */
const buttons = document.querySelectorAll(".add-to-cart")
// In VSCode, it works without importing anything. It's awesome.
buttons.forEach((button) => {
if (button.alreadyAdded) return
button.alreadyAdded = true
button.addEventListener("click", (e) => {
const target = /** @type {FancyButton} */ (e.target)
const id = target.id
const product = products.find((product) => product.id === Number(id))
if (!product) throw new Error("No product found.")
addToCart(product)
})
})
}
The worst part for me is the ugly /** @type {FancyButton} */
syntax, but it’s not too bad of a price to pay for static typing in JavaScript without a build step. Again, if you would like to see more of the type conversion process, you can watch the video above or just check out the code below for yourself.
1-JSDOC
Types in JavaScript
The strong demand for ergonomic type annotation syntax has led to forks of JavaScript with custom syntax. This has introduced developer friction and means widely-used JavaScript forks have trouble coordinating with TC39 and must risk syntax conflicts. This proposal formalizes an ergonomic syntax space for comments, to integrate the needs of type-checked forks of JavaScript. —ECMAScript proposal: Type Annotations
So, all of these methods require additional steps to achieve static typing in JavaScript. While JSDoc does not require a build step, you still must use an editor that knows how to read the annotations. Wouldn’t it be great if static typing was just a part of JavaScript?
Recently, Gil Tayar, Daniel Rosenwasser and a few others introduced a TC39 proposal to add a type syntax to the language. While this would not extend to runtime validation, it would at least standardize the syntax for type annotations. It’s not the first attempt at this (maybe not the last), but I’m hopeful that it will be accepted.
The general concept is that you would be able to use much of the TypeScript syntax that we know and love in JavaScript without worrying about removing it with compilation. While browsers still would not be able to parse the type annotations, they would simply ignore them rather than throwing an error. This would allow IDE’s and build tools to take advantage of these types without requiring a build step or JSDoc’s busy syntax. I think this is probably the right move. Other languages like Python and Ruby have done similar things, and it seems to work well for them.
Conclusion
This advice is primarily aimed at people building code for consumption in other contexts, i.e. library authors. It’s less beneficial if you’re building an application… If you’re building an app, you’re going to have a build step anyway… Because you want to optimize your code, you want to minify, you want to bundle everything up… unless you’re one of the people who have been breaking down the weaving factories in the 19th century. —Rich Harris, Dev Vlog: April 2023
So, now we have two versions of our app. One that requires a build step, and one that doesn’t. We’re going to maintain this split because I think JavaScript is at a bit of a crossroads.
On the one hand, many developers feel overwhelmed with the complexity of modern JavaScript. Some have even begun to question the need for a build step. But, modern frameworks have become so complex that it’s hard to imagine building a large application without one.
Some frameworks like Svelte have fully leaned into the extra benefits that a build step can provide. While others like Solid.js and React separate their compilation from their runtime. This means that you can technically use them without a build step, but this is pretty rare in practice.
Both approaches have their merits. React’s optional compile step has traditionally been a simple JSX transform, but they have recently been experimenting with a more advanced compiler. I think it makes sense for them to lean into this direction. They may as well take advantage of compilation if it’s going to happen anyway.
I plan on further exploring both approaches in this series. I think it’s important to understand the tradeoffs involved in each direction. One aspect that I haven’t touched on is the performance implications of a build step. Static analysis of pre-compiled code can allow for better tree-shaking,dead code elimination, and minification. And, it also allows for niceties like import aliases.
So, in the next chapter of this series, we will explore the world of bundling. We will look at the different types of bundlers, and we will see how they can help us optimize our code. At the same time, we’ll try to see how we can get some of the same benefits without a build step.
Additional Resources
-
Matt Pocock - Total TypeScript Free Tutorials
-
Jack Herrington - No BS TS
-
Austin Gil - Get Started with TypeScript the Easy Way
-
Gil Tayar - JSDoc typings: all the benefits of TypeScript, with none of the drawbacks
-
TypeScript - JSDOC supported types