Intro
Recently, I made the impactful decision to migrate my entire Node.js API from JavaScript to TypeScript. I worked with other statically typed programming languages before and therefore understood the basic concept of TypeScript. Nonetheless, I experienced some difficulties getting started with the migration, especially in terms of mongoose & express. In this post, I'll include (hopefully π ) everything needed to migrate your API over to TypeScript. Additionally, I'll briefly cover TypeScript's advantages and migration strategies.
Prerequisites:
- basic knowledge of TypeScript's syntax; I encourage you to check out the 5 minute tutorial for JS Developers before reading on
- some exposure to statically typed programming languages
Enjoy your read π
Basics
Migration Strategies
I had quite a hard time deciding whether or not I should convert my API to TypeScript. Especially when there's a lot of logic that needs to be migrated. There's always the question of Effort vs. Benefit, and there's no general answer in terms of which path you should take. It all depends on your project's particular needs. If you're not sure about migrating, I believe the comment section of my reddit post very helpful.
However, the bigger your project and especially if you're planning on scaling your application at some point in time, TypeScript will definitely make your life easier.
In terms of how to migrate, there are basically two options:
- migrate everything at once, "short & sweet": all
.js
files are converted to.ts
files, with the ultimate goal of your code being fully typed, i.e. no implicitany
types are used - migrate over time, "incremental change": all JS is valid TS; therefore, you could start out slowly and migrate only those files that contain complex logic; new code is written in TS.
Both options are valid. If your project is rather small though or you're not forced to ship features, I recommend migrating everything at once. Thereby, start with the most "basic" files, i.e. those files that don't require any other files.
Otherwise, definitely choose the second approach. If your resources are required for developing features, there's no point in migrating everything at once. Add those features in TypeScript and start migrating the rest of your project once you have the required resources to do so.
Let's have a look at some of TypeScript's benefits.
Advantages & Drawbacks of TypeScript
One of TypeScript's greatest benefits are its types (duhhh). Most of the small errors that sneak in while writing code are detected. As a JS developer, you're probably familiar with the following error:
TypeError: βundefinedβ is not a function
With TypeScript, those errors aren't detected at runtime; they prevent your code from being compiled and are therefore easy to spot. If you're using VS Code, they're even highlighted as you write.
Additionally, IntelliSense is far better due to the typed nature of TypeScript, which is especially helpful if you're working on larger projects.
In a nutshell, TypeScript is more explicit and thereby far easier to maintain. Your changes are less likely to brick your entire application... #beentheredonethat π
If you're reading this article, you can probably imagine one of TypeScript's biggest drawbacks: Although there are benefits in the long run, migration takes time and work.
Additionally, in the short run, declaring all the types may seem a little extra sometimes.
Migration
1 - Preparing your project for migration
First of all, if you haven't already done so, install the typescript
package and add it to your project:
> npm install -g typescript
> npm install --save-dev typescript
Now, it's time to create the tsconfig.json
file:
> tsc --init
If you now open this file, there's a lot of stuff commented out. Feel free to uncomment the options you need. I didn't change too much in my tsconfig.json
file though:
{
"compilerOptions": {
"target": "es6", // we don't write frontend code, so es6 is fine
"module": "commonjs",
"allowJs": true, // true = not all files have to be .ts files
"outDir": "./build", // the folder in which the compiled JS files are put
"strict": true, // enables all strict type-checking options
"alwaysStrict": true, // 'use strict' at the beginning of compiled .js files
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
The comments provided alongside these options are quite extensive which is why I won't include any explanation here. However, more information on tsconfig.json
can be found here.
2 - Installing types for your modules
In order for TypeScript to recognize your modules' types, you need to install some type definitions. You can install them like so:
> npm install @types/<library name> --save-dev
We're using the --save-dev
option as we only need those type definitions during development to compile our TypeScript code; the code that is executed is pure JavaScript code and couldn't care less about any type definitions.
You can either install types for all modules right away or wait for Visual Studio Code's linter to suggest it once you import a module:
import mongoose from "mongoose" // warning appears if module types are missing
That's it, your project is ready for the migration π Now, you can start changing your file extensions from .js
to .ts
.
To compile your files, simply run:
> tsc
You'll find the compiled files in whatever location you specified for outDir
inside the tsconfig.json
file.
What follows are specific explanations of how to use mongoose & express the TypeScript way. Other than that, migration is really easy with some background knowledge on TypeScript.
3 - Imports
Right now, you've probably imported your modules like so:
var mongoose = require("mongoose")
Migrating your code to TypeScript, however, your imports should look a bit different:
1. default imports
import mongoose from "mongoose"
2. import something specific
import { Document } from "mongoose"
3. import everything
import * as mongoose from "mongoose"
4 - TS for your mongoose schemas π
Assuming we have the following schema declaration:
const MySubCollectionSchema = new mongoose.Schema({
subValue1: { type: String, required: true },
subValue2: { type: Number, required: true }
})
const AccountSchema = new mongoose.Schema({
name: { type: String, required: true },
orderCount: { type: Number, required: false}
subCollection: { type: [MySubCollectionSchema], required: true }
})
AccountSchema.statics.addFunction = function(a: number, b: number): number {
return a + b;
}
In order to use our schemas the TypeScript way (and make use of its statically typed nature), we'll create interfaces:
export interface IAccount extends mongoose.Document {
name: string,
orderCount?: number,
subCollection: mongoose.Types.DocumentArray<IMySubCollection>
}
export interface IAccountModel extends mongoose.Model<IAccount> {
addFunction(a: number, b: number): number
}
interface IMySubCollection {
subValue1: string,
subValue2: number
}
Interfaces provide TypeScript with information about the shape that values (e.g. Objects) have. The following explanation should make this more clear.
In our case, we're declaring that every document conforming to IAccount
contains certain fields. Notice the ?
which is used to make a field optional. For IAccount
, only name
and subCollection
are required. orderCount
is optional; if it is provided though, it has to be of type number
.
As we're extending mongoose.Document
, we're making use of mongoose's Document
interface, i.e. we still have access to document properties and functions like .save()
, we simply add a few fields/functions to it.
Woah. Breathe. That's quite some information.
We're also extending mongoose.Model<IAccount>
to add information about the static method addFunction
to our account model (therefore <IAccount>
). Without doing so, TypeScript doesn't know about any static methods you define on your schema.
The same goes for methods & virtuals, except that you declare them directly in your schema interface, i.e. IAccount
in the above example.
Remember: statics are defined on a mongoose model, whereas methods & virtuals are defined on mongoose documents.
In order for our Model & Documents to actually implement our interfaces, we have to model them correctly:
mongoose.model<IAccount, IAccountModel>("accounts", AccountSchema);
That's it! Now you get full IntelliSense as TypeScript knows what an Account
document looks like as well as what functions are defined on the Account
model.
5 - TS for your express Request
If you're using express, there's a high chance you attach data to the express' request
object. For example:
app.use(req: express.Request, res: express.Response, next: express.NextFunction) {
req.myCustomProp = "this is my custom prop"
}
Without extending express' request interface, you'll receive an error that myCustomProp
doesn't exist on type express.Request
. Write the following code:
export default interface IRequest extends express.Request {
myCustomProp?: string;
}
Now, simply replace
app.use(req: express.Response, ...){...}
with
app.use(req: IRequest, ...){...}
and witness the error disappear. That's it! You can now attach data to your express request
object! Attaching data to the response
object works exactly the same!
Outro
Congrats, you now know how to migrate from JavaScript to TypeScript π I sincerely hope you had a nice read and learned something new. As always, if you have any questions, just comment them and I'll try my best to help you out π
I would love to hear your feedback! Let me know what you liked or what could've been better with this tutorial - I always welcome constructive criticism! If you have any questions, just comment them and I'll try my best to help you out :)
In love with what you just read? Follow me for more content like this π
Sources
Β© GIFs from Giphy