How to make JavaScript type safe



Here's a question: Is there a way to improve JavaScript developer experience more towards static typing and still have the same exact developer process with plain JavaScript? Gladly the answer seems to be yes; You can transition to type-safe(ish) JavaScript just by adding few libraries, no modifications to the codebase are required.
Also, if you are TypeScript-curious, would like a taste of them typings and perhaps enjoy some of the benefits while at it, but don't have time/energy for the full-on transformation process, then type-safe(ish) JavaScript could be the middle-ground answer for you.
If that tickles your fancy, do read on.
What is type-safe JavaScript anyways?
Type-safe JavaScript is just plain old JavaScript combined with TypeScript compiler to infer types on the fly based on the code itself, flagging any type errors on the way, fe. when you are trying to set a string into a variable that should receive array. Once you sprinkle in some ESLint rules specifically designed for said scenario and you get type-safe JavaScript - Not quite TypeScript but certainly lot closer to it.
This whole thing started out of curiosity on TypeScript type inference with plain JavaScript, how far could you get with it and how good it actually is. Turns out, it's surprisingly good, and really moves the needle on type safety in plain JavaScript. Plus, since type safety is achieved through TypeScript's own compiler, type-safe JavaScript is already most of the way there if you want to take the next step towards full TypeScript. So pretty good value for money.
Adding type safety to JavaScript project
Like it was mentioned before, adding TypeScript compiler and type safety to a JavaScript project requires only few libraries.
Step 1 — Add TypeScript compiler
So first up, we need to add TypeScript to our project:
npm install typescript --save-dev
We also need tsconfig.json
file to configure the compiler to handle and check plain JavaScript.
Example below has ES6 (ES2015) features switched on, JSON module resolver on and which sets type
checking for everything in the directory except node_modules
.
{
"compilerOptions": {
"lib": [
"es2015"
],
"allowJs": true,
"checkJs": true,
"noEmit": true,
"noImplicitAny": true,
"resolveJsonModule": true
},
"include": [
"**/*"
],
"exclude": [
"node_modules",
"**/node_modules/**"
]
}
Step 2 — Add ESLint
Next up, we add ESLint and type-safe-es configuration preset (plus, it's peer dependencies):
npm install eslint eslint-config-type-safe-es eslint-plugin-jsdoc --save-dev
Next, add ESLint configuration file (.eslintrc.json
) which uses the type-safe-es configuration.
{
"extends": ["type-safe-es"]
}
Note that if you also use other presets, such as Airbnb, the type-safe-es preset needs to be the
last in the extends array, as the Airbnb config turns some of them off (fe.
"extends": ["airbnb-base", "type-safe-es"]
).
There you have it. Now have JavaScript type-checking in place and ESLint that enforce code that TypeScript compiler can infer types from.
Step 3 — Install type definitions
Since you are now working with full-on TypeScript types, you better get used to projects like DefinitelyTyped, which provides ready-made types for runtime environments and popular npm libraries. For example, if you are working on a Node.js environment, you will need to install Node.js types so that the TypeScript compiler can make sense of what it is seeing:
npm install @types/node --save
Similarly, for example when you install express, you would also install TypeScript types from DefinitelyTyped:
npm install express @types/express --save
Step 4 — Add tests
Next up, you probably also want to add type checks scripts so that you can easily run the checks against the whole codebase and eventually add to continuous integration environment. First, install npm-run-all:
npm install npm-run-all --save-dev
Secondly, add package.json
scripts to run TypeScript compiler and ESLint tests:
{
"scripts": {
"test": "npm-run-all test:*",
"test:lint": "eslint .",
"test:types": "tsc -p tsconfig.json"
}
}
Now, when you run npm test
ESLint will first check that all the rules that make type inference possible are observed correctly after which TypeScript compiler makes sure there are no type errors.
Writing type-safe JavaScript
To write type-safe code and fix any type errors that just popped into existence, you need to understand how the inference in TypeScript compiler works so that you can prefer the style of code that it understands the best.
Types are simply inferred by the TypeScript compiler from right-side assignments, meaning that fe. when you initialize the variables, properties etc. their types and possible type errors can be inferred automatically, without any other syntactic sugar.
Basic types
Any variables and constants need to be initialized
// BAD
let myvariable;
// GOOD (Type is inferred to string)
let str = '';
// GOOD (Type is inferred to number)
let num = 1;
// GOOD (Type is inferred to boolean)
const bool = true;
Type unions
You can also initialize variables as type unions with or logical operator
// Type is inferred to either number or string
let thing = 1 || ' ';
Note: When writing union initializations, use truthy (1, " ", true
) instead of falsy values (0, "", false
) as they seem to confuse the TypeScript compiler when it comes to unions and it can just ignore the falsy types altogether.
Arrays
Arrays can be initialized either as empty arrays or with specific types:
// Type is inferred to array
let emptyArr = [];
// Type is inferred to array of numbers and/or booleans
let arr = [1 || true];
Object types
You can also initialize complex objects which will then be inferred to the full structure and property types:
const person = {
name: '',
age: 1
};
// Type error because name property is typed to string
person.name = 1
You will also have object type-checking when assigning object to object type variable
let thing = {
name: ''
};
let person = {
name: '',
age: 1
};
// Type error as "thing" doesn't have all "person" parameters
person = thing;
// Works as "person" has all "thing "parameters
thing = person;
However the problem with these complex object types can be that they are absolute, so you can't really set optional properties. However, when in desperation you can create new objects with Object.assign that have all the required properties from the base object
// Works as all properties are present
person = Object.assign({}, person, thing);
Of course this only makes a shallow copy. If you need deeper copy, you probably need to look into Lodash etc.
Function parameters
You could initialize function parameters the same way but that has the side effect that all parameters will be marked optional and meaning TypeScript compiler doesn't raise errors on missing parameters on function calls. Better approach is to add simple JSDoc block which defines the types of the parameters.
/**
* @param {string} a
* @param {string} b
*/
const fnc = (a, b) => {}
You can add the parameter descriptions as well if you so desire, however they are not required for the compiler
/**
* @param {string} a String A
* @param {string} b String B
*/
const fnc = (a, b) => {}
Any parameter initialized in the function definition is set as optional parameter, which will override JSDoc definitions for that parameter
/**
* @param {string} a
* @param {string} b
*/
const fnc = (a, b, c = 1) => {}
Return values
For return values, the preferred way for TypeScript type inference is to infer the type from any internal functionality since the input types are known.
If you are returning complex objects, define the object with all properties in place instead of defining them one by one, as this can confuse the TypeScript compiler when returning objects.
// BAD
const obj = {}
obj.name = nameParam;
obj.valid = (nameParam > 3 ? true : false);
// GOOD
const obj = {
name: nameParam,
valid: (nameParam > 3 ? true : false),
}
Lastly, for the desperate times — JSDoc
If you run into cases where you just can't get things to align with just type inferring, you can always fall back to JSDoc and add @type
annotations for the code, as the compiler will also use these when inferring types.
/** @type {string} */
let str = '';
And for those truly desperate times, you can even do type casting with JSDoc
/** @type {Point} */(xyobj).getXCoord();
Conclusion
There are obviously caveats to this approach - It's not as type-safe as TypeScript would be (with null-values etc.) and type inference is not perfect (even though it's getting better all the time). Also, since things like interfaces aren't in JavaScript just yet, you can't really write proper object interfaces with optional properties, so object handling can be somewhat tedious sometimes.
However, it is cheap and fast way to add type safety and extra tests to an existing JavaScript project and/or take first steps towards types.
Below is a results from Microsoft's questionnaire of the people who have been using the Visual Studio Code's integrated TypeScript compiler for JavaScript, and 80% of subjects agreeing that it has indeed improved their development experience, and this is where type-safe(ish) JavaScript builds upon.
So, there we go. You should now have type-safe(ish) JavaScript in your project (+ probably quite a lot of new errors) and some idea how type safety works with plain JavaScript when using type inference from TypeScript compiler.
Now, go out there and build awesome things!