(Bonus) Typing an NPM module that has no types
The more complex your project becomes, the more likely you’ll install a third-party package that doesn’t have any type definitions, either built-in or part of Definitely Typed repository. Never fear, we can create our own type definitions to use inside our project.
This will not be easy, though. Creating a type definition for a module with no types requires us to make guesses about API of the module and what types best represent that. Sometimes we might get it wrong; other times the API will be so complicated that writing type definitions isn’t practical. Remember, the purpose of TypeScript is to help you be a more productive developer. Using any
is perfectly acceptable when all other options would take too long to implement correctly.
Third party packages come in all kinds of shapes and sizes, which means we have to adapt our type definition for each package. Because of these differences, we’ll learn this material through example. We’ll go through several different varieties of module that’s available on NPM and create our own type definitions.
We’ll start with modular libraries. You can tell that a package is modular if it has no references to global
or window
. That includes any top-level var
or function
declarations. If the package uses import
or require
, that’s a good sign too.
We’ll use a Fruit Basket package as our example, but we’ll change the API of the package between examples to demonstrate certain principles. Inside our project, we’ll first want to create a fruit-basket.d.ts
file to hold our type declarations.
In our first example, we’ll import several values from a module using ES Module syntax.
// index.ts
import { fruitBasket, Apple, eatFruit, timeToEat } from "fruit-basket";
const apple = new Apple();
fruitBasket.add(apple);
eatFruit(fruitBasket, timeToEat);
Just based on these values, we can see that fruitBasket
is likely a class instance of some kind, Apple
is a class definition, eatFruit
is a function, and timeToEat
is likely a number.
There are two ways we can declare our module. The first is a little more work, but makes it possible for us to submit our type definitions to the Definitely Typed repository later. Our type definition has to live in a folder structure that mirrors the package that we are writing definitions for. We put that folder in the node_modules/@types
folder, which lets TypeScript detect it and recognize what package the type definitions are associated with. So, if we were writing a definition in this style, our folder structure might look something like this:
.
├── package.json
├── index.ts
└── node_modules
└── @types
└── fruit-basket
└── index.d.ts
If we had more files, we would want to create separate declaration files for each file.
Then, we declare the type definitions for each of the things which are exported from the package.
// @types/fruit-basket/index.d.ts
export class Apple {
name: string;
}
export interface FruitBasket {
add(fruit: Apple): void;
}
export function eatFruit(fruitBasket: FruitBasket, time: number): void;
export const fruitBasket: FruitBasket;
export let timeToEat: number;
Let’s note a few things here.
- The implementation of
Apple
wasn’t clear, but looking at the underlying code would tell us that it has a name property. - Any JavaScript values are defined using either
const
orlet
along with a type definition. We do this forfruitBasket
andtimeToEat
. Ourfunction
declaration also counts as a value, and theApple
class automatically represents both a type and a value. - We can also define and export types. We created a
FruitBasket
interface, and then used that type when defining ourfruitBasket
value; we also export it, which makes that type available to the user of our library.
We’ll talk about how to work with global export later in this lesson, but if you want to easily turn your module into a UMD module that also provides a global value, you can do that with a single line that wraps your module in a namespace using export as namespace
.
// @types/fruit-basket/index.d.ts
export class Apple {
name: string;
}
export interface FruitBasket {
add(fruit: Apple): void;
}
export function eatFruit(fruitBasket: FruitBasket, time: number): void;
export const fruitBasket: FruitBasket;
export let timeToEat: number;
export as namespace fruitBasketLib;
That’s how you can declare a type definition in the node_modules folder.
For the rest of this lesson, we’ll define our module inside our project folder. To do this, we have to declare our module explicitly so TypeScript knows what our module’s name is.
If we were in a hurry and just wanted TypeScript to recognize all of the exports from fruit-basket
as any
, we could declare the module without adding any type definitions.
// fruit-basket.d.ts
declare module "fruit-basket";
We need a bit more if we want any type safety though. In our declaration file, we place our declarations inside a block after our module declaration, and we use the export
keyword to say what values are exposed by our package. We then need to create type definitions that match the awy the API is used.
// fruit-basket.d.ts
declare module "fruit-basket" {
export class Apple {
name: string;
}
export interface FruitBasket {
add(fruit: Apple): void;
}
export function eatFruit(fruitBasket: FruitBasket, time: number): void;
export const fruitBasket: FruitBasket;
export let timeToEat: number;
}
Things are a little different if our package has a default export. What if instead, we imported from our package using this code:
// index.ts
import fruitBasket, { Apple, timeToEat } from "fruit-basket";
This requires only slight modification. We still have to define (and name) our fruitBasket
value, but then we can use the export default
syntax to mark that value as the default export.
// fruit-basket.d.ts
declare module "fruit-basket" {
export class Apple {
name: string;
}
export interface FruitBasket {
add(fruit: Apple): void;
eat(time: number): void;
}
const fruitBasket: FruitBasket;
export default fruitBasket;
export function eatFruit(fruitBasket: FruitBasket, time: number): void;
export let timeToEat: number;
}
Exporting our eatFruit
as the default works exactly the same way, but only if we can use the esModuleInterop
flag in our TSConfig.json file. If that’s not possible, such as when we submit our code to Definitely Typed, we have to use the export =
syntax and namespaces to represent functions that are the default export. This is where things start to get a little hairy.
// fruit-basket.d.ts
declare module "fruit-basket" {
function eatFruit(fruitBasket: eatFruit.FruitBasket, time: number): void;
namespace eatFruit {
const timeToEat: number;
const fruitBasket: FruitBasket;
interface FruitBasket {
add(fruit: Apple): void;
eat(time: number): void;
}
class Apple {
name: string;
}
}
export = eatFruit;
}
We declare our function, which represents the default export, and then declare a namespace with the same name and put all of the named exports inside that namespace. We define our FruitBasket
interface inside the namespace, which makes it available to the consumer. We then reference it in our eatFruit
function declaration by accessing the interface inside the namespace with eatFruit.FruitBasket
. Then we export the function/namespace.
This shows a very important behavior which we need to remember as we create our type definitions. We already know that classes represent both values and types. We can also merge namespaces with values and types. In this case, by defining and function and a namespace with the same name, we’ve created something that is both a namespace and a value. We go through the eatFruit
namespace to access the FruitBasket
interface in our eatFruit
function definition. We won’t go into much more detail about this behavior in this course, but you can read about it in the TypeScript documentation.
If we need to import typings from other modules, we can do that using regular import
syntax inside of our module declaration. This works exactly the same as ES Modules, so we can also immediately export values and types from other modules.
// fruit-basket.d.ts
declare module "fruit-basket" {
import { Apple } from "fruit-apple";
export { Apple } from "fruit-apple";
export interface FruitBasket {
add(fruit: Apple): void;
}
export function eatFruit(fruitBasket: FruitBasket, time: number): void;
export const fruitBasket: FruitBasket;
export let timeToEat: number;
}
Global libraries behave completely differently, so our type definitions have to behave accordingly. A global library is accessed from the global scope without using any form of import
. jQuery
, d3
and many other libraries use this approach.
However many libraries are written as UMD libraries, which means they can be referenced both from the global and using an import
statement. You can tell that a library is exclusively global if it has top-level variable and function declarations, or if it explicitly references the global
or window
objects, without defining any kind of exports.
Suppose our fruit-basket
library were accessed on the global object. Using it would look something like this:
// index.ts
const { Apple, fruitBasket, eatFruit, timeToEat } = fruitBasketLib;
const apple = new Apple();
fruitBasket.add(apple);
eatFruit(fruitBasket, timeToEat);
Namespaces are TypeScript’s way of referencing global objects. If our library is truly global, we just wrap all of our definitions in a namespace.
// fruit-basket.d.ts
declare namespace fruitBasketLib {
class Apple {
name: string;
}
interface FruitBasket {
add(fruit: Apple): void;
eat(time: number): void;
}
const timeToEat: number;
const fruitBasket: FruitBasket;
function eatFruit(fruitBasket: FruitBasket, time: number): void;
}
Referencing other type declarations can be done using a triple-slash reference.
// fruit-basket.d.ts
/// <reference types="fruit-apple" />
declare namespace fruitBasketLib {
interface FruitBasket {
add(fruit: fruitApple.Apple): void;
eat(time: number): void;
}
const timeToEat: number;
const fruitBasket: FruitBasket;
function eatFruit(fruitBasket: FruitBasket, time: number): void;
}
Additional TSConfig.json options
TypeScript has over 100 different configuration options which can be used to fine-tune how the type checker and compiler work. We’ve already covered many of these in previous lessons, such as strict
, module
, and lib
. This lesson will cover several other options.
To help keep things simple, I’ve condensed the full list of options down to the most helpful options that you might be reaching for in a TypeScript project, organized by what they are used for. In a future lesson, we’ll talk about options for TypeScript’s module resolution algorithm. You can find the rest of the options in the TypeScript Documentation Reference.
Including and excluding certain files
These first three properties aren’t included in the compilerOptions
section of tsconfig.json like most of the other properties. They are their own top-level properties. We use them to tell TypeScript what files in the project should be type checked and compiled by TypeScript and which ones to ignore.
files
The files
property is a list of paths, relative to the tsconfig.json file, to all of the files in your project. If your project is small and doesn’t necessarily include any dependencies from node_modules
, using the files
can make TypeScript speedier since it doesn’t have to comb your filesystem for the files that are present in your project. All you have to do is include every file in the list.
{
"compilerOptions": {},
"files": ["fruitBasket.ts", "fruit/apple.ts", "fruit/banana.ts"]
}
This can get tedious, though, so most often you’ll use include
and exclude
. files
is not mutually exclusive with include
and exclude
though; you can always list specific files in your program with files
.
include
Typical projects have a few directories, some of which have source code that needs to be checked and some which have support files that don’t need to be checked. By default, TypeScript will compile all .ts
and .tsx
files in the same directory as the tsconfig.json file. We can selectively include the files that we need using the include
.
Both include
and exclude
use a wildcard character system when evaluating the glob patterns you use in the file paths. **/
matches any directory nested at any level, *
matches 0 or more characters in a file or directory name, and ?
matches exactly one character in a file or directory name.
We can use these rules to create a glob that matches any file extensions with .ts
or .js
, but not .json
or .css
, inside our src
directory.
src/**/*.?s
We can leave off the extension altogether, since TypeScript will automatically include .ts
, .tsx
, and .d.ts
(along with their JavaScript counterparts). Here’s a very common include
setting.
{
"compilerOptions": {},
"include": ["src/**/*"]
}
One thing to remember is that include
only tells TypeScript where to start. If one of our files imports a module from a file that is not matched by our include
list, that file will still be compiled and type checked by TypeScript.
exclude
When using include
, you’ll also want to explicitly exclude some directories and files. One obvious example is the node_modules
folder. We definitely do not want to have to compile all of our third-party module code if we don’t have to.
The exclude
property acts as a mask to the files we specified in our include
property. That means that it doesn’t actually exclude files altogether; it just keeps files from being picked up by the TypeScript compiler.
One notable exception is the node_modules
folder. TypeScript doesn’t work as a bundler like Webpack does, so it won’t ever try to pull out or compile files that you import from node_modules
. You should still include node_modules
in your exclude
list, but don’t worry about those files being added to your project’s output when TypeScript compiles.
{
"compilerOptions": {},
"exclude": ["node_modules/**/*", "scripts/**/*"]
}
Improving the developer experience
incremental
When your TypeScript project starts getting big, your build times might increase substantially. TypeScript provides a few options to make your project build faster and use less memory in these circumstances. If your project is pretty small, this changes might be helpful, but likely won’t make a large difference.
Usually when TypeScript compiles or type checks your code, it does it all at once. Changing even a small part of your project would still require TypeScript to at least look at other files that are imported and exported, and eventually every file has been checked anyway.
When incremental
is turned on, TypeScript will keep a cache of the compilation results for itself when it runs a full compile. On subsequent compiles, it will use this cache to skip parts that don’t need to be compiled again, making the overall build faster. The cache exists in the same folder as the build output, and have no relation to the actual JavaScript output files.
{
"compilerOptions": {
"incremental": true
}
}
sourceMap
Source maps are generated files which provide a map from compiled output, such as the .js
files which TypeScript generates, back to the original .ts
source, without having to include the source files themselves. They can be included in build output and shipped to production environments without affecting the end-user’s experience since source maps are only loaded when the user opens the developer tools.
Turning on source maps will increase your build time, but could be helpful when trying to debug your code in a production environment. Turning on source maps is as easy as activating the sourceMap
flag.
{
"compilerOptions": {
"sourceMap": true
}
}
You can also include your source maps inline with your JavaScript files using the inlineSourceMap
option, but that will increase the size of your output files and could degrade your user experience. You should only use that option if the server that you are serving your code on doesn’t support source maps.
checkJs
If your project doesn’t have any .js
files, or if you specifically don’t want TypeScript to check your .js
files, you can turn off the checkJs
option. This will still let TypeScript compile your JavaScript files, but it won’t give you any warnings or errors if your JavaScript has any type errors. This is helpful when incrementally migrating a JavaScript project to TypeScript.
{
"compilerOptions": {
"checkJs": false
}
}
jsx
If you are working with React or another library that uses JSX, you can configure how TypeScript compiles the JSX syntax. If you are working with React, you’ll want to set this setting to react
; otherwise, you can set it to preserve
, which will keep the JSX syntax in place without changing it.
You can also use the jsxFactory
option to change which function is used to compile JSX Elements. By default, it uses React.createElement
, but if we were working with a Preact project, we would want to use preact.h
. Here’s what the full Preact configuration might look like.
{
"compilerOptions": {
"jsx": "react",
"jsxFactory": "preact.h"
}
}
Remember, whenever you are working with JSX, the JSX code has to be inside a .tsx
file. If you are having strange errors with your JSX, double check the file extension.
Configuring the Compiler output
outDir
This lets you specify where your compiled source will be placed relative to the tsconfig.json file. This includes source maps and declaration files, if applicable. If the directory doesn’t exist, TypeScript will make it before outputting the compiled files.
{
"compilerOptions": {
"outDir": "build"
}
}
noEmit
This option tells the TypeScript compiler to only type check our code, not output any files. This is incredibly useful if we are using TypeScript with a separate build tool, like Webpack, Babel, or Parcel. We can still use TypeScript to type check our files with a single command, perhaps as part of our CI/CD process, but the actual compilation will be handled by the other tools in our toolchain.
(Bonus) How the target
field works
The lib
property lets us specify what version of JavaScript our source files can use; the target
property lets us say what version of JavaScript our output files use. This is useful when we want to use newer features of JavaScript while still supporting older browsers and environments.
The default setting for target
is “ES3”, which means it supports all the way back to Internet Explorer 8! These days, it’s rare for anyone to be using a browser that doesn’t support at least ES2015, so we can usually change the target
field to ES2015
. Supporting more recent JavaScript versions, like ES2019
, might decrease the size of your bundle and make your program more efficient, since you don’t have to include large polyfills and JavaScript engines can optimize newer syntax to make it faster than older syntax. Ultimately, the choice is up to you and what browser versions your users have.
In short, pick a lib
value that covers what code you’ll be writing , and pick a target
value that covers your code output . You want your target
to be as recent as possible, while still supporting all the people that need to run your code.
Just for fun, let’s see how a simple piece of TypeScript code is compiled into different target
s.
Source:
class Fruit {
constructor(public name: string, public color?: string) {}
}
class FruitBasket {
static maxFruit: number = 5;
#fruitList: Fruit[] = [];
addFruit(fruit: Fruit) {
// Throw away green fruit
if (fruit?.color === "green") return;
if (this.#fruitList.length < FruitBasket.maxFruit) {
this.#fruitList.push(fruit);
}
}
eat() {
this.#fruitList.pop();
}
}
ES5 (20 lines longer):
var __classPrivateFieldGet =
(this && this.__classPrivateFieldGet) ||
function (receiver, privateMap) {
if (!privateMap.has(receiver)) {
throw new TypeError("attempted to get private field on non-instance");
}
return privateMap.get(receiver);
};
var Fruit = /** @class */ (function () {
function Fruit(name, color) {
this.name = name;
this.color = color;
}
return Fruit;
})();
var FruitBasket = /** @class */ (function () {
function FruitBasket() {
_fruitList.set(this, []);
}
FruitBasket.prototype.addFruit = function (fruit) {
// Throw away green fruit
if ((fruit === null || fruit === void 0 ? void 0 : fruit.color) === "green")
return;
if (
__classPrivateFieldGet(this, _fruitList).length < FruitBasket.maxFruit
) {
__classPrivateFieldGet(this, _fruitList).push(fruit);
}
};
FruitBasket.prototype.eat = function () {
__classPrivateFieldGet(this, _fruitList).pop();
};
var _fruitList;
_fruitList = new WeakMap();
FruitBasket.maxFruit = 5;
return FruitBasket;
})();
ES5 doesn’t have class support, so it has to make the necessary adjustments to the prototype of FruitBasket to make it behave like a class.
To get private fields working, ES5 has to add a special polyfill which uses a WeakMap to keep track of the fruit list. That adds a considerable number of lines to the output, and isn’t as performant as proper private fields could be.
Since this polyfill requires WeakMap support, this polyfill isn’t even compatible with ES5. We would have to use TypeScript private
fields instead.
ES2015 (18 lines longer):
var __classPrivateFieldGet =
(this && this.__classPrivateFieldGet) ||
function (receiver, privateMap) {
if (!privateMap.has(receiver)) {
throw new TypeError("attempted to get private field on non-instance");
}
return privateMap.get(receiver);
};
var _fruitList;
class Fruit {
constructor(name, color) {
this.name = name;
this.color = color;
}
}
class FruitBasket {
constructor() {
_fruitList.set(this, []);
}
addFruit(fruit) {
// Throw away green fruit
if ((fruit === null || fruit === void 0 ? void 0 : fruit.color) === "green")
return;
if (
__classPrivateFieldGet(this, _fruitList).length < FruitBasket.maxFruit
) {
__classPrivateFieldGet(this, _fruitList).push(fruit);
}
}
eat() {
__classPrivateFieldGet(this, _fruitList).pop();
}
}
_fruitList = new WeakMap();
FruitBasket.maxFruit = 5;
We are able to shave off a few lines of implementation by using ES2015 classes instead of prototypes. Private fields are still not supported, so we need that lengthy polyfill at the top.
ESNext (6 lines longer)
class Fruit {
constructor(name, color) {
this.name = name;
this.color = color;
}
}
class FruitBasket {
constructor() {
this.#fruitList = [];
}
#fruitList;
addFruit(fruit) {
// Throw away green fruit
if (fruit?.color === "green") return;
if (this.#fruitList.length < FruitBasket.maxFruit) {
this.#fruitList.push(fruit);
}
}
eat() {
this.#fruitList.pop();
}
}
FruitBasket.maxFruit = 5;
A future version of JavaScript will ship with proper private field support, which will dramatically reduce the implementation size by getting rid of the polyfill. Without the polyfill, we won’t need to use that WeakMap hack, which should provide more security and more opportunity for browser efficiency improvement.
Module Resolution
When TypeScript is compiling your project, it will start with the files in your files
or includes
list, but as we’ve found, it doesn’t stop there. If one of the TypeScript files imports any other modules, TypeScript will load in that file as well. If it can’t find the file, it will throw an error and the compilation will fail.
TypeScript knows how to find files based what you put in your import
statement using a process called Module Resolution. TypeScript is configured to use a module resolution strategy that is very similar to the one Node.js uses, so if you’ve used Node.js, Webpack, or something similar, it should be familiar. If you want more information you can read about it in the TypeScript docs.
TypeScript lets you configure the module resolution strategy, which can be useful depending on the size of your project and how you’ve structured it. There are a number of settings which affect module resolution, so we’ll go through each of them and how you can use them to make your project easier to work with.
Relative vs. Non-Relative Imports
Before we move on, it’s important to note the difference between relative and non-relative imports.
A non-relative import is anything that just references a file or a module that is not relative to the current file. This includes any file in node_modules
. For example, import * as THREE from 'threejs';
will have TypeScript look in the closest node_modules
directory for a file called threejs.js
, and then the threejs
folder that has an index.js
file inside it. If it can’t find a matching file in the current directory, it will jump up one level and search again. If it can’t go up any more levels, it fails and throws an error.
Relative imports are entirely based on where the file that is doing the importing is located. These imports start with ./
or ../
and tell TypeScript to look around the importing file to find the file we’re looking for. For example, if I was in /src/FruitBasket/index.ts
and had import Apple from '../Fruit/apple';
, TypeScript would first look for the /src/Fruit/apple.ts
file, then a /src/Fruit/apple/index.ts
file.
One important thing to remember is that you can write TypeScript code that is not completely compliant with ES Modules syntax. In ES Modules, you have to specify the extension of the file you are importing, and you can’t import a folder. If you want to take advantage of TypeScript’s nuanced module resolution system, you can leave the extension off files that you are imported. However, if you are targeting ES Modules, you’ll want to include the .js
extension, even if you are pointing to a .ts
file in your imports. Don’t worry, TypeScript will know what file you are referencing, and when the code compiles, it will come out with the correct path to your module file.
Important Note
These next settings alter the way that you write your import
statements. Before I go any further, it’s very important to note that these settings have some pretty big implications.
First, this affects the way your code is written, since we are changing how our import
statements are written. The TypeScript type checker will be able to tell whether you’ve written your code correctly, and it will be able to compile your code correctly. But other tools, like Webpack, might get confused as they traverse your dependency tree.
Second, this affects the output of the TypeScript compiler. If you change the way module resolution works and adjust the imports in your code accordingly, those adjustments to your code will transfer into the output. Loading the compiled code in a browser or Node.js will make those environments look for modules exactly where the import statement says they are. If the browser or Node.js can’t find the files because the files aren’t in the right place, there will be an error.
All of this means you’ll need to make adjustments to how your code is consumed to match the changes you make to these settings. Most bundlers, like Webpack, support adjusting module resolution, as does Node.js with the correct packages. If we’re working with ES Modules in a browser environment, we have to rearrange our directory structure on our static file server to match the import statements. We’ll talk about how to handle module resolution in the sections on configuring TypeScript to work in different environments.
In short, if you change these settings, make sure everything else is configured to consume your TypeScript code properly.
baseUrl
baseUrl
is a setting that changes where non-relative imports are resolved from. They don’t affect relative modules at all.
Suppose we have a directory structure that looks something like this.
FruityApp
├── src
│ ├── index.ts
│ ├── fruit
│ │ ├── apple.ts
│ │ ├── banana.ts
│ │ ├── fruit.ts
│ │ └── pear.ts
│ └── fruitBasket
│ └── index.ts
└── tsconfig.json
If we were to use a relative import to access apple.ts
from the fruitBasket/index.ts
file, we would have to do something like this.
import Apple from "../fruit/apple";
Going back just one level in our directory structure is fine, but it can get a little tedious if we are importing a file from even deeper within our project’s directory structure.
import Banana from "../../../../fruit/pear";
What baseUrl
does is tells TypeScript to treat whatever folder we specify as the root of our project and allow us to use it for accessing modules with a non-relative import. It basically treats that root folder the same as our node_modules
folder. If we were to change it to the src
directory, we would be able to import anything in src
without having to navigate up our directory structure.
import Pear from 'fruit/pear`
It can be helpful to this about this in terms of websites. Suppose our app is hosted at https://fruityapp.ui.dev
. Using relative imports would work exactly the same, regardless of our baseUrl
setting. But our non-relative import would be resolved relative to the base of our app. If we adjusted our Pear
import to support ES Modules, our browser would look for that file at https://fruityapp.ui.dev/fruit/pear.js
.
This setting is helpful in two ways. First, it lets us clean up our code when working with deeply nested relative imports. Second, it lets us change what the import root for our project is in our compiled code when we’re using the TypeScript compiler. If we were using a bundler like Webpack, the bundler would take care of the import root for us anyway.
paths
TypeScript gives us one more tool we can use control our non-relative imports. The paths
setting lets us specify specific patterns to match specific modules. These patterns accept wildcard ( *
) characters, so they can be pretty flexible.
Let’s use the same folder directory again. This time, instead of changing our baseUrl
, we’ll create a special path for our fruit
folder that can be non-relative imported from anywhere in our project. When using the paths
setting, we need to specify a baseUrl
, since all of the paths we list will be relative to the baseUrl
.
The paths
setting is a map of the import pattern with a list of directory patterns that it matches. In our case, it looks like this:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@fruit/*": ["src/fruit/*"]
}
}
}
I’m using @fruit/*
as my path alias to hopefully avoid any name collisions. Now we can use our non-relative import in fruitBasket/index.ts
.
import Apple from "@fruit/apple";
Notice how we wrote the path. In the property name, we wrote the shorthand followed by *
. In the value list, we wrote the directory path followed by *
. In our import, TypeScript replaced the shorthand with the full directory path, so we could import our file correctly.
As you might guess, we can add multiple items to each paths
entry to serve as fallback locations. For example, if our directory structure looked more like this:
FruityApp
├── src
│ ├── fruit
│ │ ├── apple.ts
│ │ ├── banana.ts
│ │ ├── fruit.ts
│ │ └── pear.ts
│ └── fruitBasket
│ └── index.ts
├── generated
│ └── fruit
│ ├── strawberry.ts
│ └── lime.ts
└── tsconfig.json
We could change our paths
setting to first look in the src/fruit
folder and then look in the generated/fruit
folder.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@fruit/*": ["src/fruit/*", "generated/fruit/*"]
}
}
}
paths
and ES Modules
The paths
setting can really upset the way ES Modules work in browsers. For one thing, all ES Module imports have to begin with a /
, ./
or ../
. Fortunately we can simulate that behavior with paths
.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"/*": ["src/*"]
}
}
}
What if we are using ES Modules to import modules from a CDN, like unpkg.com
? This is fairly common when working with small libraries like Preact. With ES Modules, we can import directly from a url.
import { h } from "https://unpkg.com/preact@10.5.2/dist/preact.min.js";
The only problem with this is type checking. TypeScript doesn’t download and install files that our project imports from CDNs or external URLs. That means we can’t access the type declaration files which are bundled with Preact, so TypeScript can’t type check our code.
Using paths
, we can create a direct connection between this external URL import and a file in our node_modules
directory. First, we need to install Preact locally. This will put an index.d.ts
inside our node_modules
directory. Then, in our paths
setting, we can connect our import to that declaration file.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"/*": ["src/*"],
"https://unpkg.com/preact@10.5.2/dist/preact.min.js": [
"node_modules/preact/src/index.d.ts"
]
}
}
}
Now Typescript will be able to properly check the types, but we’ll still be loading the module from the CDN when our code runs in a browser environment.
(Bonus) Configuring for Webpack Development
Webpack is incredibly common for web development these days. There are two options we have when configuring it to work with TypeScript. We can either use a TypeScript loader, or we can use the Babel loader and configure Babel to compile TypeScript code. Since we cover configuring Babel in another lesson, we’ll just focus on the TypeScript loader.
The first thing we’ll want to do is install all of the necessary dependencies.
npm install --save-dev webpack webpack-cli webpack-dev-server html-webpack-plugin typescript ts-loader
Webpack will handle determining our dependency tree and writing our bundled code, but TypeScript will be used to do the actual compilation. That means we need to configure a few options around what we want our compiled output to be.
We’ll set the target
to “es2015”, although we could change this based on what browsers we need to support. We’ll also set module
to “commonjs”, since it provides the most compatibility with Webpack. We’ll also make sure esModuleInterop
and isolatedModules
are turned on.
{
"compilerOptions": {
"target": "es2015",
"module": "commonjs",
"isolatedModules": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
We also need to configure our webpack.config.js file. We’ll specify the entry point for our our project, as well as the output bundle location. We’ll tell Webpack to resolve .ts
and .tsx
files in addition to .js
. Then we’ll configure a module loader that finds any files with a .ts
or .tsx
extension and load them with the ts-loader
module loader. This will invoke TypeScript with our tsconfig.json options for every TypeScript file that Webpack finds.
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
mode: "development",
entry: "./src/index.ts",
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "build"),
},
resolve: {
// Add `.ts` and `.tsx` as a resolvable extension.
extensions: [".ts", ".tsx", ".js"],
},
module: {
rules: [
// all files with a `.ts` or `.tsx` extension will be handled by `ts-loader`
{ test: /\.tsx?$/, use: "ts-loader", exclude: /node_modules/ },
],
},
plugins: [new HtmlWebpackPlugin()],
};
We can then run Webpack using npx webpack
. Webpack will run and create a bundle for us in the ‘output’ directory. This is just the basic config; we would need to include other loaders for CSS and other file types.
We can also serve our webpack app as a webpage with live reloading using Webpack Dev Server. We can do this by running npx webpack-dev-server
. This will start a web server that will automatically reload when we change our source code. Using the HtmlWebpackPlugin
has Webpack create a blank HTML file and injects our output bundle in the <body>
of our page.
Working with non-code assets
It’s common for Webpack projects to import non-code assets, like images, CSS, SCSS, and more. TypeScript doesn’t know how to deal with these types of files, so we need to create a simple type declaration file to handle these. To do this, we’ll create a custom.d.ts
file to hold all of our type definitions for the non-code assets Webpack might load. Here is a declaration for .png files.
// custom.d.ts
declare module "*.png" {
const content: any;
export default content;
}
This tells TypeScript that any files that end in “.png” export an any
value, which lets us use it however we need without TypeScript complaining. If we knew exactly how Webpack transformed the file type, we could adjust the type definition. For example, if .png files were imported as URL strings, we could change the type to string
.
Handling baseUrl
and paths
If you use the baseUrl
or paths
settings in tsconfig.json, you can easily configure Webpack to pick up these settings. Installing and enabling tsconfig-paths-webpack-plugin
will make your module resolution settings just work with Webpack.
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
module.exports = {
...
resolve: {
plugins: [new TsconfigPathsPlugin({ configFile: "./path/to/tsconfig.json" })]
}
...
}
Notice that the plugin is placed in the resolve.plugins section of the configuration. This is intentional. As the docs explain:
tsconfig-paths-webpack-plugin is a resolve plugin and should only be placed in this part of the configuration. Don’t confuse this with the plugins array at the root of the webpack configuration object.
(Bonus) Configuring for Babel Development
Using Babel with TypeScript is a common way to transform your code, since Babel often runs faster in certain contexts and gives you access to the entire Babel ecosystem. However, there are some caveats which make developing with Babel a little more complicated.
- Babel doesn’t type check your code. For that, you’ll still want to use TypeScript with the
noEmit
tsconfig.json flag enabled. - Babel doesn’t read your tsconfig.json file to get your configuration. That means any settings which affect the compiler have to be set on the Babel plugin directly.
- Many TypeScript features aren’t supported directly or at all. Const Enums and the
export =
syntax both require additional Babel plugins to work, and behavior of namespaces is also limited. If you are using Babel, it’s best to avoid all of these TypeScript features.
Also, since the TypeScript Babel plugin only removes the TypeScript type definitions, we will have to rely on Babel to do any JavaScript transformations, such as transpiling to an earlier version of JavaScript.
To enable Babel in your TypeScript project, you’ll first want to install the necessary dependencies.
npm install --save-dev @babel/core @babel/cli typescript @babel/preset-env @babel/preset-typescript
Then, create a .babelrc
file and include the following presets.
{
"presets": ["@babel/preset-env", "@babel/preset-typescript"]
}
Your tsconfig.json file is only going to affect the way your code is type checked, not compiled. For that reason, we can ignore many of the settings. The most important setting is noEmit
, since that will stop TypeScript from compiling our code.
Another important setting is isolatedModules
. TypeScript includes this setting to provide certain warnings that make it easier for external build tools, like Babel, to transpile TypeScript code better.
{
"compilerOptions": {
"strict": true,
"isolatedModules": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true
},
"include": ["src/**/*"]
}
Then you can execute Babel to compile your code.
npx babel src --extensions ".ts" -d build
And you can type check your code by running the TypeScript command.
tsc
For more information on configuring the TypeScript Babel plugin, check out the Babel docs.
(Bonus) Configuring for Modern Web Development
Almost all modern web development these days is done with a bundler, like Webpack. Bundlers make it easy to specify an entry point, load and compile all of the dependencies (including those in the node_modules
directory), and create a set of output files. This makes the build process more predictable and creates a nice developer experience. However, it isn’t required, and simple sites work just fine without using a bundler. In addition, ES Modules make it easier than ever to write modern JavaScript that depends on other modules without needing a bundler.
We can use TypeScript to compile the code for our app, and use ES Modules to load any external scripts or extra files in our project. For this to work, we’re making a few assumptions.
- Everyone who uses our app is using a modern browser that supports ES Modules.
- The only third-party code we are using is coming from a CDN, like unpkg.com or skypack.dev.
- Our project isn’t very large and doesn’t need to use automated complicated tree shaking or code splitting strategies.
If any of these don’t apply to your project, you’ll likely want to use a bundler. Check out the bonus lessons on how to configure TypeScript for your specific environment.
This would probably be best explained using an example. Let’s suppose our project is a simple countdown timer built with vanilla JavaScript/TypeScript. It has the following folder structure.
TimerApp
├── package.json
├── public
│ └── index.html
├── src
│ ├── index.ts
│ └── utils
│ ├── calculateRemainingTime.ts
│ └── createButton.ts
└── tsconfig.json
We won’t be going over the code that is in each of these files, just the folder structure.
We’ll be using strict
mode for our TypeScript code. If we wanted to, we could configure TypeScript to use a separate loader like “AMD” or “SystemJS”. However, since we are targeting modern browsers, we’ll use ES Modules.
We’ll be using a single NPM module, date-fns
to do the necessary calculations. Since TypeScript doesn’t work as a bundler for us, we’ll load it from the skypack.dev CDN. That means we’ll have to modify our paths
setting to support type checking for the third-party module.
We’ll put our build output into the “public” directory, alongside our HTML file.
Finally, we’ll turn on source maps so we can easily debug our code in the developer tools.
Here’s what our tsconfig.json file looks like:
{
"compilerOptions": {
"target": "es2015",
"module": "esnext",
"sourceMap": true,
"outDir": "public",
"strict": true,
"baseUrl": "./",
"paths": {
"https://cdn.skypack.dev/date-fns@^2.14.0'": [
"node_modules/date-fns/typings.d.ts"
]
},
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}
We’ll have to install the date-fns
NPM module so we have access to that type declarations file.
npm install date-fns
When writing our program, we need to make sure that we include the file extension on our import statements. For example, if we have a function that creates the DOM element for a button, we would import it like this.
// src/index.js
import { createButton } from "./utils/createButton.js";
Remember, TypeScript will still recognize this correctly during development, and when it is compiled the relative link will be pointing at the correct file.
Our index.html
file in our public
folder will be fairly simple. All we need to do is include a <script>
tag that points at our index.js
file and a <div>
to put our application in. Note the special type="module"
attribute that we add on our script tag. This is necessary to tell the browser to load this script as an ES Module.
<!DOCTYPE html>
<html lang="en">
<head>
<title>Timer</title>
</head>
<body>
<div id="root"></div>
<script src="index.js" type="module"></script>
</body>
</html>
We can then compile our code with TypeScript by running the tsc
command.
tsc
This will build our TypeScript code. We can also build our code in watch mode so TypeScript automatically compiles our code as we change it.
tsc --watch
Then, we can start a webserver pointed at our public
directory and view our site. One quick way to do this is with the serve
NPM package.
npx serve public
We should see our type checked, compiled, and running site!
(Bonus) Configuring for Node Development
Node.js has slightly different requirements than browsers, so we’ll need to adjust our tsconfig.json file to work correctly.
Since version 13, Node.js has had limited support for ES Modules. However, CommonJS is much more common (pun intended) in the Node ecosystem, so we’ll have our compiler output CommonJS-compliant files. We can still use ES Modules in our source code, though, especially if we have esModuleInterop
turned on.
We’ll also want to change our target
field to whatever version of JavaScript our version of Node.js supports. We’ll use the isolatedModules
flag to make sure each of our files is a module. Finally, we use resolveJsonModule
to allow us to import .json files as modules (as Node.js already supports).
Here’s what a sample tsconfig.json would look like.
{
"compilerOptions": {
"target": "es2015",
"module": "commonjs",
"outDir": "build",
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}
We also will want to include the @types/node
package so that TypeScript understands the type definitions of Node’s APIs.
npm install --save-dev @types/node
We can then run our TypeScript compiler to transform our code, and then run the code in the output folder with Node.js.
tsc
node build/index.js
It’s also possible to have TypeScript compile our code on-the-fly as it is required by Node.js. A package called ts-node
lets us do just that. We can run it by pointing it at a TypeScript file.
npx ts-node src/index.ts
This approach works well for small scripts that we run ourselves and for development, but isn’t ideal for production environments, since the TypeScript compiler has to continue running, consuming precious memory. Instead, it’s best to compile our TypeScript and run Node.js directly.
If we are in a development environment, it can get a little tedious to continuously stop the Node.js server, recompile our application, and restart Node.js whenever we make a change. Tools like Nodemon make this easier for Node.js development by restarting the Node process whenever a file changes. However, they aren’t ideal for TypeScript development, since the TypeScript process takes a little while to restart.
ts-node-dev
is a package which wraps ts-node
and makes it so our Node.js process is restarted without restarting TypeScript. This leads to faster development feedback loops, since we don’t have to wait for TypeScript to start up every time we make a change. ts-node-dev
works exactly the same as ts-node
- just point it at a file. Any changes to that file or its dependencies will cause a refresh.
npx ts-node-dev src/index.ts
Handling baseUrl
and paths
If you use the paths
option in your tsconfig.json file, we can very easily configure Node.js to recognize those adjustments. The tsconfig-paths
package automatically reads the configuration in the tsconfig.json file and adjusts Node.js’ module system to support the baseUrl
and paths
configuration we used. We can register it with Node.js (or ts-node
or ts-node-dev
) with Node’s -r
flag.
npm install --save-dev tsconfig-paths
# then
node -r tsconfig-paths/register build/index.js
# or
npx ts-node-dev -r tsconfig-paths/register src/index.ts
Note that this requires including your tsconfig.json file with your production Node.js code, so tsconfig-paths
knows what your configuration is.
(Bonus) Configuring for Library Development with TSDX
If you are making a library that you intend to release on NPM, there are a lot of pieces to consider. How can you make your package work with multiple module systems? How can you make sure the production bundle is as small as possible? What about adding tests and linting?
While I could spend the next 10 minutes explaining every possible configuration and optimization, there are tools which have collected best practices for modern NPM packages and rolled them into a nice, easy to use package.
TSDX is a Zero-config CLI for TypeScript package development. That means you only need to run one command, and it will configure everything for you with great defaults and the ability to adjust any setting as needed.
To create a new TypeScript library, just run:
npx tsdx create mylib
This will set you up with a TypeScript project that outputs to CommonJS and ES Modules, tree-shakes and minifies your build, lints your code, and even warns you if your package is getting to large. If you need to customize any of the tools that TSDX uses, you can follow their configuration guide.
That’s it. That’s the whole lesson.
Recursive Conditional Types
ES2019 introduced the .flat()
method to arrays. If an array has another array as one of its items, it spreads those items into the original array, thus flattening it. It even supports specifying a depth parameter so you can flatten nested arrays, like so:
const flattened = [
[1, 2],
[3, 4, ["a", "b"]],
].flat(4);
// [1,2,3,4,"a","b"]
The type of the result is (string|number)[]
, since the final result has both strings and numbers in the array. I’ll let you explore TypeScript’s built-in type definitions to see how it manages that.
For our purposes, we’re going to write a function that takes advantage of the .flat
method to deeply flatten an array so there are no more nested arrays inside of it. In other words, we’ll call .flat
with an infinite depth. How would we type that?
function deepFlatten<T extends any[]>(array: T): T[] {
return array.flat(Infinity);
}
const flattened = deepFlatten([
[1, 2],
[3, 4, ["a", "b"]],
]); // const flatten: (number | string[])[][][]
This flat
method takes in the array and returns the same array with no modification. This is what would happen if we called .flat(0)
. It’s a good start, but it doesn’t properly represent the arrays being combined together. How could we do that?
We know that we can unwrap an array using conditional types and the infer
keyword; we could try that.
type UnwrapArray<T> = T extends (infer R)[] ? R : T;
function deepFlatten<T extends any[]>(array: T): UnwrapArray<T>[] {
return array.flat(Infinity);
}
const flattened = deepFlatten([
[1, 2],
[3, 4, ["a", "b"]],
]); // const flatten: (number | string[])[][]
That helped us unwrap one of the arrays, but we still don’t have a fully flattened type definition. The challenge here is that the .flat
method is a recursive function. What would be nice is if we could use our UnwrapArray
type inside of itself. That way, if R
is possibly still an array, we could unwrap it again; otherwise, we return the non-array T
value. It’s a recursive conditional type!
type UnwrapArrayRecursive<T> = T extends (infer R)[]
? UnwrapArrayRecursive<R>
: T;
function deepFlatten<T extends any[]>(array: T): UnwrapArrayRecursive<T>[] {
return array.flat(Infinity);
}
const flattened = deepFlatten([
[1, 2],
[3, 4, ["a", "b"]],
]); // const flatten: (string | number)[]
TypeScript recursively unwraps our nested array type until it arrives at the fully non-array types. It then unions those together to tell us the type of every element of our nested array. The return type of our function is an array of those elements, since they are now occupying a flat array.
Recursive conditional types are new in TypeScript 4.1. Before, you would have to write out incredibly complicated types with strange hacks. Now, recursive conditional types are built in with automatic depth limiting. If TypeScript executes the same recursive conditional too many times, it will give you a warning. Also, be aware that for very complicated types, it might increase your type checking time. In many cases, it might be best to write out the types by hand.
Template Literal Types
We learned earlier that we can create unions of literal strings. These are used to represent object keys, specific string parameters, and finite states. In our example, we created a union of season names.
type Seasons = "spring" | "summer" | "autumn" | "winter";
Suppose for a moment that we wanted to create a value that represents the dates that the seasons begin. It might look something like this:
const seasonsStartDate = {
springStart: "March, 20",
summerStart: "June, 20",
autumnStart: "September, 22",
winterStart: "December, 21",
};
How would we construct a type that represents this object structure? We could use a mapped type, but our Seasons
type only has the name of the season, not the -Start
suffix. Of course, we could write out every property in the type so it has the suffix, but we already have our literal type. Why don’t we just do some kind of string interpolation, like we can with JavaScript?
type SeasonsStartDate = {
[P in `${Seasons}Start`]:string
}
// type SeasonsStartDate = {
// springStart: string;
// summerStart: string;
// autumnStart: string;
// winterStart: string;
// }
Holy cow, it worked! Starting with TypeScript 4.1, you can interpolate string literals together. The syntax is exactly like JavaScript, except instead of passing in string values, you pass in string literal types.
Notice how TypeScript automatically created an interpolation with every member of our union. This is a really powerful aspect of template literal interpolation that makes it possible to create combinations of many strings, such as this one which gives us positioning schemes.
type VerticalPositions = "top"|"middle"|"bottom"
type HorizontalPositions = "left"|"center"|"right"
type Positions = `${VerticalPositions}-${HorizontalPositions}`
// "top-left" | "top-center" | "top-right" | "middle-left" | "middle-center" | "middle-right" | "bottom-left" | "bottom-center" | "bottom-right"
TypeScript creates string literals for every permutation of strings based on the unions that are passed in. This is really useful, and can be very powerful. Just remember that TypeScript has to process all of those permutations. In this case, having two unions of three options yielded nine different string literals; adding another union of three strings would give us 3^3 combinations, or 27. The calculations which TypeScript has to perform grow exponentially, so in many cases it’s best to just use string
.
Template Literal Constraint
In the example above, we created nine specific options which our type represents. Sometimes, though, we want a string that represents many values, but matches a specific pattern. We can create a type which does this using non-literal types in our type interpolation.
type StringDashString = `${string}-${string}`;
const correctPosition:StringDashString = "forward-backward"
const incorrectPosition:StringDashString = "Some other value"
// Type Error: Type '"Some other value"' is not assignable to type '`${string}-${string}`'.
We can interpolate number
types as well. We can use this to represent several number patterns, such as phone numbers or IP addresses. Obviously, this only checks the presence of any number, not whether it’s a valid number, such as being less than 255 in the case of IP Addresses; we would need runtime checking for that.
type PhoneNumber = `(${number}) ${number}-${number}` // (555) 315-4483
type IPAddress = `${number}.${number}.${number}.${number}` // 192.168.1.5
Template Literal Inference
You might recall when we learned about conditional types, we could hook into the type inference system to unwrap our types. We can do the same thing with template literal types.
By placing the infer
keyword inside a template tag, we can parse a literal value. This example uses a conditional type to pull literal values out of a string and put them into a tuple.
type MatchTuplePair<S extends string> = S extends `[${infer A},${infer B}]` ? [A,B] : unknown;
type StringTuple = MatchTuplePair<`[hello,world]`> // type StringTuple = ["hello", "world"]
You can also take advantage of implied inference, where TypeScript automatically detects the type of something without us having to use the infer
keyword. Suppose we were creating a function which adds an on
method to an object that we pass in. This method lets you pass in the name of a property with the Changed
suffix along with a callback that is called whenever that property is modified. The type of the callback parameter should be the same as the type of the property it matches. Using it would look something like this:
declare function watchableProperties<T>(obj: T): T & AddOnMethod<T>;
let fruit = watchableProperties({
name: "Apple",
color: "red",
sweetness: 80,
});
fruit.on("nameChanged", (newName) => {
// (parameter) newName: string;
});
We’re using an intersection type operator to add an on
method to our object via the AddOnMethod type. That’s the only piece of this that’s missing, so lets start with that.
type AddOnMethod<T> = {
on(eventName: string, callback: (newValue: unknown) => void): void;
};
This barely models the function, but we get no type checking on the eventName
parameter, and we would have to use type narrowing to figure out what the newValue
type actually is.
All of this can be inferred by TypeScript. We can create a new generic called K to be used with the on
method, and have it extend the keys of T. Then, we can use that for the types of both eventName
using template literals and newValue
using indexed type access. Because of the nature of tagged templates, we’ll also have to constrain K to only represent strings using an intersection type operator.
type AddOnMethod<T> = {
on<K extends string & keyof T>(eventName:`${K}Changed`, callback:(newValue:T[K]) => void):void;
}
Now, we’ll get type warnings if we accidentally use the wrong event name, or if we use the wrong type for newValue
.
Template Literals and Recursive Conditionals
Using recursive conditionals, we can create more complicated utility types that work with template literal types. For example, if we had a literal type that has whitespace on either side, we could create a trim type to remove that whitespace.
type WhitespaceString = " hi there ";
type Trim<S extends string> =
S extends ` ${infer T}` ? Trim<T> :
S extends `${infer T} ` ? Trim<T> :
S;
type TrimmedString = Trim<WhitespaceString> // type TrimmedString: "hi there"
There are a lot more uses for template literal types. For example, one ambitious TypeScript user created an Express route parser that lets you type check your URLs based on the route definition. The same user made one that returns the correct DOM element type for complex CSS queries. Another person made a JSON string parser just using the TypeScript type checker!
These are all very advanced, and very complicated examples. Don’t be discouraged by them! Rather, recognize how much power is available in the type system with these tools at your disposal.
Mapped Types Key Remapping
In the previous lesson, we learned about using template literal types. This allowed us to define new properties on objects based on the names of existing properties. In this lesson, we’ll dynamically modify those property names, while still enjoying type safety for the property values.
In the last lesson, we dynamically created properties on an object by using a mapped type with template literal types.
type Seasons = "spring" | "summer" | "autumn" | "winter";
type SeasonsStartDate = {
[P in `${Seasons}Start`]:string
}
This worked fine when we were dealing with an explicit union of strings and a simple value type. What would happen if we made it generic? In this example, we’ll create a function which takes an object and adds a setX
method for each property. This setX
method would take a callback that accepts the type of the property as a parameter and returns the type of the property. This allows immutable modifications to the property based on its current value.
declare function addSetMethods<T extends object>(
object: T
): T & SetFunctions<T>;
const fruit = addSetMethods({
name: "Apple",
color: "red",
sweetness: 80,
});
fruit.setName((currentName) => `Ripe ${currentName}`);
We can see that there is a setName
method added to this fruit object; there’s also a setColor
and setSweetness
. We do this by creating an intersection of the T
type that represents the object and SetFunctions<T>
which creates the type definitions for the extra functions.
Let’s see if we can create the SetFunctions
type. We’ll use a mapped type where each property is a function that takes a callback. We’ll even use a template literal type to create a new property name.
type SetFunctions<T> = {
[P in `set${keyof T}`]: (callback:(currentValue: T[P]) => T[P]) => void;
}
// Type Error: Type 'keyof T' is not assignable to type 'string | number | bigint | boolean'
// Type Error: Type 'P' cannot be used to index type 'T'.
We have two type errors. The first happens in our template literal type. TypeScript expects us to use a string
here, but keyof T
could possibly be a number
. We can solve this by using an intersection of string
and keyof T
to constrain P
.
type SetFunctions<T> = {
[P in `set${string & keyof T}`]: (callback: (currentValue: T[P]) => T[P]) => void;
}
// Type Error: Type 'P' cannot be used to index type 'T'.
The second issue has to do with the type of P
. We’re creating new properties on T
, but since those properties don’t exist, we can’t access the type on them. We can fix this issue temporarily by constraining T
to allow string index access on the type. We can use the Record
utility type to do this.
type SetFunctions<T extends Record<string, any>> = {
[P in `set${string & keyof T}`]: (callback: (currentValue: T[P]) => T[P]) => void;
}
Okay, that fixes all of the type errors. What does our function look like now?
const fruit = addSetMethods({
name: "Apple",
color: "red",
sweetness: 80,
});
fruit.setname((currentName) => `Ripe ${currentName.toUpperCase()}`);
// Type Error: Property 'toUpperCase' does not exist on type 'unknown'.
It looks like we’ve got some more problems. The most obvious is the type error. TypeScript wasn’t able to infer the type of our function’s parameter, so it uses unknown
, which is very unhelpful.
Also, the casing on our method name is awful. setname
? Is that the best we can do?
We can tackle the first problem with Mapped Types key renaming.
Mapped Types Key Renaming
We jumped through a lot of hoops to get our original generic type working correctly. This is because, until recently, TypeScript didn’t support renaming keys when creating a mapped type. We can now create new property names with template literal types, but we need a way to connect the new property name with the old property’s type.
Enter Mapped Types Key Renaming, a new feature in TypeScript 4.1. Let’s rewrite the SetFunctions
type to properly rename our keys, then I’ll explain what’s going on.
type SetFunctions<T> = {
[P in keyof T as `set${string & P}`]: (callback: (currentValue: T[P]) => T[P]) => void;
}
I’m using the as
keyword to say that we’re looping over all the properties of T
, but changing the property name based on the template literal type that we define. We can then transform the property’s value based on the type of T[P]
, like we do here to create our type safe callback function.
We can use this to exclude properties from our return type as well, without having to use the Omit
utility type. If we make our new property name never
, that property will be removed from the type altogether.
type SetFunctionsExceptColor<T> = {
[P in keyof T as `set${string & Exclude<P,"color">}`]: (callback: (currentValue: T[P]) => T[P]) => void;
}
//...
fruit.setcolor(...) // Type Error: Property 'setcolor' does not exist on type
Intrinsic Utility Types
TypeScript now ships with a number of intrinsic utility types. These are utilities which aren’t represented by type annotations; rather, the type system modifies the type internally. Currently, there are only four intrinsic utility types, and they all relate to changing the casing of literal types.
We can use the Capitalize
utility type to capitalize the first letter of our property name to make the casing more sensible.
type SetFunctions<T> = {
[P in keyof T as `set${string & Capitalize<P>}`]: (callback: (currentValue: T[P]) => T[P]) => void;
}
//...
fruit.setName(...) // 🙌
Our method names are now in camel case.
There are also intrinsic utility types for Uppercase
, which makes the whole string uppercase; Lowercase
, which makes the whole string lowercase, and Uncapitalize
which makes the first character lowercase.
Unexpected TypeScript Behavior
Throughout the course, we’ve talked about all the ways that TypeScript tries to make sure your code is correct and free of type errors. However, the way that it is designed lends itself to some interesting and unexpected behaviors. All of these behaviors have reasonable explanations, but they might not be entirely obvious at first.
This section is all about explaining ways that TypeScript can behave strangely, why it does what it does, and how we can get around that.
Structural Typing
We’ve covered this in a previous section, but it’s important to reiterate, since this is the basis for most of these strange behaviors. TypeScript uses a structural typing system, also known as duck typing. This means it checks to see if the properties of types match, rather than identifying two types as being different if they were defined in different places.
Take this for example:
interface Apple {
name: string;
color: string;
}
interface Banana {
name: string;
}
const fruit: Apple = { name: "Banana", color: "Yellow" };
const otherFruit: Banana = fruit;
Even though I’ve specifically said that otherFruit
is a Banana
type, we can still assign an Apple
value to it, since Apple
has the same properties as Banana
. Apple even has an extra property, but TypeScript doesn’t mind. So long as it has the properties it needs, it will ignore any extra properties we assign.
Type Erasure
The second thing which has the biggest impact on strange behaviors is type erasure. This means that when our code is compiled from TypeScript to JavaScript, all of the type information is removed from our code. This includes interfaces, type aliases, type signatures, and generics.
There is a distinction between types and values. Types describe the structure of a value, while the value holds information in the structure the type specifies. So the value of an object sticks around after compilation, but the interface that describes the shape of that object is removed.
That means you can’t try and convert types into values, such as using the typeof
operator. That won’t work, because our types are erased at compilation. Here is an example that does not work to illustrate this issue.
// THIS WILL NOT WORK
function eatIfString<T>(fruit: T) {
if (typeof T === "string") {
// Do a thing
}
}
This doesn’t work, because T
doesn’t have a value. It represents a type. What we can do is check the type of the value which is passed into our function instead.
// THIS WILL WORK
function eatIfString<T>(fruit: T) {
if (typeof fruit === "string") {
// Do a thing
}
}
Destructuring Objects
ES2015 brought us object and parameter destructuring, a handy way to access properties that exist in objects. You might assume that we can just put our type definitions directly on the values we destructure, like so.
// THIS WILL NOT WORK
function getFruitName({ name: string }) {
return name;
}
The problem with this has to do with destructuring syntax. Putting something after a colon in the destructuring list actually changes the name of the variable from the property name to whatever you put after the colon. So in this case, we’re putting the name
property into a variable called string
, which is obviously not what we wanted to do.
Instead, we have to add our type definition to the whole object.
// THIS WILL WORK
function getFruitName({ name }: { name: string }) {
return name;
}
// or even better
function getFruitName({ name }: Fruit) {
return name;
}
Void Functions
Having a function with void
as the return type is helpful when we want to make sure we don’t accidentally return something from our function. However, you might find a situation where you use a function that actually does return something in a place where it should be returning void
.
function performCallback(callback: () => void) {
callback();
}
let list: number[] = [];
performCallback(() => list.push(10));
The callback
function we pass in implicitly returns a number value, so TypeScript should be throwing an error to tell us that the return values don’t match up.
However, remember that TypeScript is here to keep us from writing type errors in our code. There isn’t a type error here. Yes, we do return a value from our callback
function. However, that value isn’t assigned to a variable or used in any way. TypeScript just ignores it. This gives us a little bit of flexibility when working with callbacks like this without needing to worry about our types matching up perfectly.
Fewer Function Parameters
Lets look at a similar example. In this case, our callback signature takes multiple parameters, but the callback function we pass in only uses one of them.
const numbers = [1, 2, 3];
const doubled = numbers.map((item: number) => num * 2);
What’s going on here? We know that our .map
callback has three parameters: the item, the index, and the entire array. We’re only using one of those parameters.
TypeScript doesn’t care how we use the parameters that are passed in, since our callback might not need all of the parameters. All that TypeScript cares about is that all of the parameters that we do use have the correct type. In this case, we’ve used the appropriate type for the item parameters, so TypeScript doesn’t throw an error.
Empty Interfaces
Here’s a fun one. What happens when we don’t put any properties on an interface?
interface Empty {}
const myObject: Empty = { hello: "World" };
const myArray: Empty = [1, 2, 3];
const myString: Empty = "This works";
That’s right. Assigning values to an empty Interfaces operate exactly the same as the any
type. That’s because when we assign the value, TypeScript checks to make sure all of the properties of our interface are present. Since our interface has no properties, that means all of them are present, so the assignment works just fine.
This same behavior exists on Classes as well. As a rule of thumb, never create Interfaces or Classes with no properties.
Object Literals
Okay, we know that when we create an Interface, we can assign other variables to it that have more properties than necessary. That means we should be able to assign object literals with more properties than necessary, right?
interface Fruit {
name: string;
}
const apple = { name: "Apple", color: "red" };
const anotherApple: Fruit = apple; // This works just fine.
const banana: Fruit = { name: "Banana", color: "yellow" }; // Type Error: Object literal may only specify known properties, and 'color' does not exist in type 'Fruit'.
What happened? TypeScript has a special rule around object literals. If we assign an object literal to a variable that has a specific type, we can’t include any extra properties. Since we have full control over the structure of the object literal (we made it in the first place), TypeScript expects us to remove the extra properties.
We can get around this by asserting that our object literal is the expected type.
const banana: Fruit = { name: "Banana", color: "yellow" } as Fruit; // This works just fine.
Type Assertions vs Type Casting
When we use the as
keyword in TypeScript, like we just did above, we are asserting that a certain value is a certain type. We are not, however, actually transforming the value from one type into another, just convincing TypeScript that the types are the way we say they are.
That means we can use the as
keyword to break the type checker and cause runtime type errors. For example, we can assert any value into any type by first using the as unknown
assertion.
const myAge: number = ("perpetually youthful" as unknown) as number;
console.log(myAge * 2); // NaN
In this case, JavaScript’s very permissive type casting system helped us out by turning the result into a number instead of throwing a runtime type error. However, if we were to try accessing a number method, like .toFixed
, JavaScript would throw Uncaught TypeError: myAge.toFixed is not a function
.
If I wanted to explicitly turn my string
into a number
, I could use several different methods to cast the value.
const myStringAge = "perpetually youthful";
const myAge: number = parseInt(myStringAge);
console.log(myStringAge + 2); // "perpetually youthful2"
console.log(myAge + 2); // NaN
Whenever you are using the as
keyword, or any time you are trying to convince TypeScript that a value is a certain type, be very careful to make sure you don’t accidentally introduce runtime type errors that TypeScript can’t detect.
(Bonus) Experimental Decorators
Decorators are an experimental feature of TypeScript that allow you to add extra powers to ES2015 classes. The implementation which TypeScript uses is different from the TC39 proposal (currently in Stage 2), so using decorators is discouraged. However, if you find yourself working in a codebase that uses decorators, it could be helpful to know how they work.
Decorators are functions that we can attach to classes and their members. These decorator functions get different parts of the class as their parameters, and allow us to do something with that class (or class member). Decorators allow us to reuse logic between multiple classes without resorting to class inheritance.
Since this is an experimental feature, you need to turn on the experimentalDecorators
option in tsconfig.json.
There are several different kinds of decorators; we’ll go over each of them.
Class Decorator
We can attach a function to a class which will be called when that class is instantiated. The function gets the class constructor as it’s first parameter. We have to return a class definition. Typically we extend the class that is being decorated.
type Instantiable = new (...args: any[]) => any;
function makeEdible<TClass extends Instantiable>(target: TClass) {
return class Edible extends target {
edible = true;
};
}
We’re using the Instantiable
type to represent any object that can be instantiated using the new
keyword, including classes. By using the generic TClass
type to represent our class, we can maintain type safety and only allow our function to be used when decorating classes.
We can then decorate our class using @
followed by our function.
@makeEdible
class Fruit {
constructor(public name: string) {}
}
console.log(new Fruit("Apple")); // class Fruit {name:"Apple", edible:true}
Suppose we wanted to pass parameters into our decorator. We can do that by creating a “decorator factory”. This is a function which returns our decorator function, and it looks something like this:
function setEdible(isEdible: boolean = true) {
return function makeEdible<TClass extends Instantiable>(target: TClass) {
return class Edible extends target {
edible = isEdible;
};
};
}
@setEdible(false)
class Fruit {
constructor(public name: string) {}
}
console.log(new Fruit("Apple")); // class Fruit {name:"Apple", edible:false}
Property Decorators
We can use property decorators to add metadata or logic to a class property. This is done using property descriptors, which are explained in much better detail in this MDN article.
We can use it to transform a regular property into an accessor property, with getters and setters. The first parameter is the class prototype; we’ll use any to represent it. We also get the name of the property we decorated as the second parameter.
We’ll create a set of functions which set the decorated property to be uppercase. We’ll store the value outside of the class instance that we don’t destroy our call stack with recursive calls to our setter function.
function Uppercase(target: any, key: string) {
let val = target[key];
const getter = () => {
return val;
};
const setter = (newVal: string) => {
val = newVal.toUpperCase();
};
Object.defineProperty(target, key, {
get: getter,
set: setter,
enumerable: true,
configurable: true,
});
}
class Fruit {
@Uppercase
public name = "Apple";
constructor(name: string) {
this.name = name;
}
}
console.log(new Fruit("Apple")); // class Fruit {name:"APPLE"}
We can also use the same “function returning another function” factory pattern to create property decorator factories.
Method Decorators
Method decorators work similarly to property decorators; they are a function where the first parameter is the class prototype and the second is the method name. The third property is the property descriptor for the method which can be modified directly.
function Loggable(target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`Executed ${key} method.`);
return original.apply(this, args);
};
return descriptor;
}
class Fruit {
public name = "Apple";
constructor(name: string) {
this.name = name;
}
@Loggable
sayName() {
console.log(`${this.name} Fruit`);
}
}
(Project) Initial NodeJS Setup
The starting point for this video can be found on Github. The commit for this video can be found here.
(Project) Static Web Server
The starting point for this video can be found on Github. The commit for this video can be found here.
(Project) Multiple Routes
The starting point for this video can be found on Github. The commit for this video can be found here.
(Project) Dynamic API Route
The starting point for this video can be found on Github. The commit for this video can be found here.
Outro
If you made it this far, I’m incredibly humbled that you’d take the time to go through this course. As you can imagine, it was no small task to create, but I’m hopeful it’s been worth your time. The question now is, where do you go from here? As always, the answer is “Go build stuff!”. Specifically, head over to Github and start working on the curriculum. Don’t skip this part! It’s critical that you put what we’ve talked about into practice.
Last thing. If you’d like to leave a review for the course, it would mean a lot to us. You can do that below.