Hi! 🖖
Today, I'll walk us through moving an Express NodeJS app from JavaScript
to TypeScript.
Why? TypeScript offers type safety "on demand", most of the code
won't break if you move your app from one to the other, and then, you
can add the safety where it is important.
How
We are going to start from a fork of Kent C. Dodds' Express example for
mid-large
apps.
I made a branch called javascript
as a starter.
Nothing is lost, nothing is created, everything is transformed
Let's change the extension of all our app's js files to ts:
$ find . -type f -name '*.js' | grep -v node_modules | grep -v babelrc | while read line; do name=$(echo $line | sed 's/\.js$/.ts/'); mv $line $name; done
We find all js files, ignore node_modules and babelrc, and rename them
to ts.
Adding TypeScript
- Let's add the dependencies
$ yarn add typescript --dev
$ yarn add concurrently @types/express --dev
And in package.json
, we add more scripts:
"scripts": {
"start": "node .",
"build": "babel --delete-dir-on-start --out-dir dist --copy-files --ignore \"**/__tests__/**,**/__mocks__/**\" --no-copy-ignored src",
"start:dev": "nodemon dist/index.js",
"build:dev": "tsc --watch --preserveWatchOutput",
"dev": "concurrently \"npm:build:dev\" \"npm:start:dev\""
},
- Init the config
$ yarn tsc --init
You can copy my tsconfig.json
, I mainly added an output dire and small things like that.
- Run the TypeScript compiler, crash and burn
$ yarn tsc
So, this breaks. Now let's fix the issues
Fixing a File
Let's start with a small file: src/index.ts
. It returns an error that
seems straightforward, but is representative of how TypeScript can be
annoying with little things.
Here is the content of the file:
import logger from 'loglevel'
import {startServer} from './start'
const isTest = process.env.NODE_ENV === 'test'
const logLevel = process.env.LOG_LEVEL || (isTest ? 'warn' : 'info')
logger.setLevel(logLevel)
startServer()
And the error:
src/index.ts:7:17 - error TS2345: Argument of type 'string' is not
assignable to parameter of type 'LogLevelDesc'.
So here, we can see that logger.setLevel()
is used to set the log
level, taking a logLevel
variable. And it is going to be a string from
the LOG_LEVEL
environment variable if defined, else based on the
NODE_ENV
variable, it will be a string: 'warn' or 'info'.
HOWEVER, this crashes now, because in TypeScript, setLevel()
takes
a LogLevelDesc
type, which is essentially an integer with a fancy type
name.
Common libraries have types well documented, toplevel not really. So
I had to look at examples in the node_modules
:
$ grep -rHin setlevel node_modules | less
node_modules/loglevel/test/node-integration.js:11:
log.setLevel(log.levels.TRACE);
node_modules/loglevel/test/node-integration.js:12:
log.setLevel(log.levels.DEBUG);
node_modules/loglevel/test/node-integration.js:13:
log.setLevel(log.levels.INFO);
node_modules/loglevel/test/node-integration.js:14:
log.setLevel(log.levels.WARN);
node_modules/loglevel/test/node-integration.js:15:
log.setLevel(log.levels.ERROR);
... So here we have some usage, for us it is going to be
logger.levels.INFO
, etc, so we replace "warn"
and "info"
in const
by
logLevel = process.env.LOG_LEVEL || (isTest ? 'warn' : 'info')
logger.levels.WARN
and logger.levels.INFO
It's still not enough, because process.env.LOG_LEVEL
is still
potentially there, and it's going to be a string. So I had to write
a function to convert the string and cast it in a LogLevelDesc
:
const convertLogLevel: (logLevel: string | undefined) => logger.LogLevelDesc = (
logLevel: string | undefined,
) => {
switch (logLevel) {
case "1":
case "error":
return logger.levels.ERROR;
case "2":
case "warn":
return logger.levels.WARN;
default:
return logger.levels.INFO;
}
};
const isTest = process.env.NODE_ENV === "test";
const logLevel: logger.LogLevelDesc = convertLogLevel(process.env.LOG_LEVEL) ||
(isTest ? logger.levels.WARN : logger.levels.INFO);
As you can see in the first line, I had to specifically write the type
of the function (logLevel: string | undefined) => logger.LogLevelDesc
(a function signature is (param1: type, param2: type, ...) =>
).
returnType
I strongly recommend that you use a linter for your editor, so you can
see type errors while writing the code.
Now that this file is fixed, let's try another one with Express code so
we see how this works for bigger, better documented libraries,
Fixing an Express Route File
Now let's fix src/routes/math.ts
. There is a problem with implicit
any
type for req, res, etc. This can be solved by defining an explicit
type any for those
:
async function add(req: any, res: any) {}
Types for request and stuff aren't safe and more of adding another
headache than a solution. I prefer creating a type for the query
parameters, this is more useful.
type MathQuery = {
a: number;
b: number;
c: number;
};
async function add(req: any, res: any) {
const mathQuery = req.query as MathQuery;
const sum = Number(mathQuery.a) + Number(mathQuery.c);
res.send(sum.toString());
}
So here, we cast req.query
as MathQuery.
Some Battles You Can't Win
We've seen well done transition to TypeScript, this latest compromise,
now we'll see a case where it is too painful to fix the code so we
ignore it.
I am a partisan of using TypeScript when it is useful, and try to use
the type system the most possible, to avoid errors at runtime.
That said, there are times when it is just too exhausting, painful and
a waste of time to use. Here for example, the src/start.ts
file is
a good example. Kent has wrapped the startServer
and middleware
functions in promises with no type, no real return, just a resolution.
And I'm sure he knows what he's doing a lot better than me.
There is no way to match this signature without overriding or modifying
the node type definitions, so in that case when we know it's working,
it's faster and probably best just to ignore the type verification.
Simply add // @ts-nocheck
at the top of the file.
Top comments (0)