About a year ago I wrote a guide on how to migrate to typescript from javascript on node.js and it got more than 7k views. I did not have much knowledge on javascript nor typescript at the time and might have been focusing too much on certain tools instead of the big picture. And the biggest problem is that I didn't provide a solution to migrating large projects where you obviously not going to rewrite everything in a short time, thus I feel the urge to share the greatest and latest of what I learned on how to migrate to typescript.
The entire process of migrating your mighty thousand-file mono-repo project to typescript is easier than you think. Here's 3 main steps on how to do it.
NOTE: This article assumes you know the basics of typescript and use Visual Studio Code
, if not, some details might not apply.
Relevant code for this guide: https://github.com/llldar/migrate-to-typescript-the-advance-guide
Typing Begins
After 10 hours of debugging using console.log
, you finally fixed that Cannot read property 'x' of undefined
error and turns out it's due to calling some method that might be undefined
: what a surprise! You swear to yourself that you are going to migrate the entire project to typescript. But when looking at the lib
, util
and components
folder and those tens of thousands of javascript files in them, you say to yourself: 'Maybe later, maybe when I have time'. Of course that day never come since you always have "cool new features" to add to the app and customers are not going to pay more for typescript anyway.
Now what if I told you that you can migrate to typescript incrementally and start benefiting from it immediately?
Add the magic d.ts
d.ts
files are type declaration files from typescript, all they do is declaring various types of objects and functions used in your code and does not contain any actual logic.
Now considering you are writing a messaging app:
Assuming you have a constant named user
and some arrays of it inside user.js
const user = {
id: 1234,
firstname: 'Bruce',
lastname: 'Wayne',
status: 'online',
};
const users = [user];
const onlineUsers = users.filter((u) => u.status === 'online');
console.log(
onlineUsers.map((ou) => `${ou.firstname} ${ou.lastname} is ${ou.status}`)
);
Corresponding user.d.ts
would be
export interface User {
id: number;
firstname: string;
lastname: string;
status: 'online' | 'offline';
}
Then you have this function named sendMessage
inside message.js
function sendMessage(from, to, message)
The corresponding interface in message.d.ts
should look like:
type sendMessage = (from: string, to: string, message: string) => boolean
However, our sendMessage
might not be that simple, maybe we could have used some more complex types as parameter, or it could be an async function
For complex types you can use import
to help things out, keep types clean and avoid duplicates.
import { User } from './models/user';
type Message = {
content: string;
createAt: Date;
likes: number;
}
interface MessageResult {
ok: boolean;
statusCode: number;
json: () => Promise<any>;
text: () => Promise<string>;
}
type sendMessage = (from: User, to: User, message: Message) => Promise<MessageResult>
NOTE: I used both type
and interface
here to show you how to use them, you should stick to one of them in your project.
Connecting the types
Now that you have the types, how does them work with your js
files?
There are generally 2 approaches:
Jsdoc typedef import
assuming user.d.ts
are in the same folder, you add the following comments in your user.js
:
/**
* @typedef {import('./user').User} User
*/
/**
* @type {User}
*/
const user = {
id: 1234,
firstname: 'Bruce',
lastname: 'Wayne',
status: 'online',
};
/**
* @type {User[]}
*/
const users = [];
// onlineUser would automatically infer its type to be User[]
const onlineUsers = users.filter((u) => u.status === 'online');
console.log(
onlineUsers.map((ou) => `${ou.firstname} ${ou.lastname} is ${ou.status}`)
);
To use this approach correctly, you need to keep the import
and export
inside your d.ts
files. Otherwise you would end up getting any
type, which is definitely not what you want.
Triple slash directive
Triple slash directive is the "good ol'way" of import
in typescript when you are not able to use import
in certain situations.
NOTE: you might need to add the following to your eslint config file
when deal with triple slash directive
to avoid eslint errors.
{
"rules": {
"spaced-comment": [
"error",
"always",
{
"line": {
"markers": ["/"]
}
}
]
}
}
For message function, add the following to your message.js
file, assuming message.js
and message.d.ts
are in the same folder
/// <reference path="./models/user.d.ts" /> (add this only if you use user type)
/// <reference path="./message.d.ts" />
and then add jsDoc
comment above sendMessage
function
/**
* @type {sendMessage}
*/
function sendMessage(from, to, message)
You would then find out that sendMessage
is now correctly typed and you can get auto completion from your IDE when using from
, to
and message
as well as the function return type.
Alternative, you can write them as follows
/**
* @param {User} from
* @param {User} to
* @param {Message} message
* @returns {MessageResult}
*/
function sendMessage(from, to, message)
It's a more of a convention to writing jsDoc
function signatures. But definitely more verbose.
When using triple slash directive
, you should remove import
and export
from your d.ts
files, otherwise triple slash directive
will not work , if you must import something from another file use it like:
type sendMessage = (
from: import("./models/user").User,
to: import("./models/user").User,
message: Message
) => Promise<MessageResult>;
The reason behind all these is that typescript treat d.ts
files as ambient module declarations if they don't have any imports or exports. If they do have import
or export
, they will be treated as a normal module file, not the global one, so using them in triple slash directive
or augmenting module definitions
will not work.
NOTE: In your actual project, stick to one of import and export
or triple slash directive
, do not use them both.
Automatically generate d.ts
If you already had a lot of jsDoc
comments in your javascript code, well you are in luck, with a simple line of
npx typescript src/**/*.js --declaration --allowJs --emitDeclarationOnly --outDir types
Assuming all your js files are inside src
folder, your output d.ts
files would be in types
folder
Babel configuration(optional)
If you have babel setup in your project, you might need to add this to your babelrc
{
"exclude": ["**/*.d.ts"]
}
To avoid compiling the *.d.ts
files into *.d.js
, which doesn't make any sense.
Now you should be able to benefit from typescript (autocompletion) with zero configuration and zero logic change in your js code.
The type check
After at least more than 70% of your code base is covered by the aforementioned steps, you now might begin considering switch on the type check, which helps your further eliminate minor errors and bugs inside your code base. Don't worry, you are still going to use javascript for a while, which means no changes in build process nor in library.
The main thing you need to do is add jsconfig.json
to your project.
Basically it's a file that define the scope of your project and defines the lib and the tools you are going to work with.
Example jsonconfig.json
file:
{
"compilerOptions": {
"module": "commonjs",
"target": "es5",
"checkJs": true,
"lib": ["es2015", "dom"]
},
"baseUrl": ".",
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
The main point here is that we need checkJs
to be true, this way we enable type check for all our js
files.
Once it's enabled, expect a large amount of errors, be sure fix them one by one.
Incremental typecheck
// @ts-nocheck
In a file, if you have some js
file you would rather fix later , you can // @ts-nocheck
at the head of the page and typescript complier would just ignore this file.
// @ts-ignore
What if you just want you ignore 1 line instead of the entire file? Use // @ts-ignore
. It will just ignore the line below it.
// @ts-expect-error
It's like @ts-ignore
, but better. It allows typescript compiler to complain when there's no longer error somewhere, you'll know to remove this comment.
These three tags combined should allow you fix type check errors in your codebase in a steady manner.
External libraries
Well maintained library
If you are using a popular library, chances are there are already typing for it at DefinitelyTyped
, in this case, just run:
yarn add @types/your_lib_name --dev
or
npm i @types/your_lib_name --save-dev
NOTE: if you are installing a type declaration for an organisational library whose name contains @
and /
like @babel/core
you should change its name to add __
in the middle and remove the @
and /
, resulting in something like babel__core
.
Pure Js Library
What if you used a js
library that the author archived 10 years ago and did not provide any typescript typing? It's very likely to happen since the majority of the npm models still use javascript. Adding @ts-ignroe
doesn't seem like a good idea since you want your type safety as much as possible.
Now you need to augmenting module definitions
by creating a d.ts
file, preferably in types
folder, and add your own type definitions to it. Then you can enjoy the safe type check for your code.
declare module 'some-js-lib' {
export const sendMessage: (
from: number,
to: number,
message: string
) => Promise<MessageResult>;
}
After all these you should a have pretty good way to type check your codebase and avoid minor bugs.
The type check rises
Now after you fixed more than 95% of the type check errors and is sure that every library have corresponding type definitions. You may process to the final move: Officially changing your code base to typescript.
NOTE: I will not cover the details here since they were already covered in my earlier post
Change all files into .ts
files
Now it's time to merge the d.ts
files with you js files. With almost all type check errors fixed and type cover for all your modules. What you do is essentially changing require
syntax to import
and putting everything into one ts
file. The process should be rather easy with all the work you've done prior.
Change jsconfig to tsconfig
Now you need a tsconfig.json
instead of jsconfig.json
Example tsconfig.json
Frontend projects
{
"compilerOptions": {
"target": "es2015",
"allowJs": false,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"noImplicitThis": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"typeRoots": ["node_modules/@types", "src/types"],
"baseUrl": ".",
},
"include": ["src"],
"exclude": ["node_modules"]
}
Backend projects
{
"compilerOptions": {
"sourceMap": false,
"esModuleInterop": true,
"allowJs": false,
"noImplicitAny": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"preserveConstEnums": true,
"strictNullChecks": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"lib": ["es2018"],
"module": "commonjs",
"target": "es2018",
"baseUrl": ".",
"paths": {
"*": ["node_modules/*", "src/types/*"]
},
"typeRoots": ["node_modules/@types", "src/types"],
"outDir": "./built",
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Fix any addition type check errors after this change since the type check got even stricter.
Change CI/CD pipeline and build process
Your code now requires a build process to generate to runnable code, usually adding this to your package.json
is enough:
{
"scripts":{
"build": "tsc"
}
}
However, for frontend projects you often would need babel and you would setup your project like this:
{
"scripts": {
"build": "rimraf dist && tsc --emitDeclarationOnly && babel src --out-dir dist --extensions .ts,.tsx && copyfiles package.json LICENSE.md README.md ./dist"
}
}
Now make sure your change your entry point in your file like this:
{
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
}
Then you are all set.
NOTE: change dist
to the folder you actually use.
The End
Congratulations, your codebase is now written in typescript and strictly type checked. Now you can enjoy all typescript's benefits like autocomplete, static typing, esnext grammar, great scalability. DX is going sky high while the maintenance cost is minimum. Working on the project is no longer a painful process and you never had that Cannot read property 'x' of undefined
error ever again.
Alternative method:
If you want to migrate to typescript with a more "all in" approach, here's a cool guide for that by airbnb team
Top comments (4)
Hi, I'd like to translate this excellent guide to Chinese. Can you give me the permission? If permitted, the translated text will be published at nextfe.com. There will be a backlink to this original article, of course. :-)
Ok
Finished the translation at the weekend: nextfe.com/migrate-to-typescript/ BTW, during the translation, I encountered a possible typo. "and them add jsDoc comment above sendMessage function", I guess you meant "and then add" instead.
Oh thanks for pointing out, I'll update them.