Jump to content

TypeScript without TypeScript

TypeScript is developer experience

It’s 2023, and it’s undeniable that TypeScript has become a de facto standard for JavaScript development. There are several reasons for its impact and popularity. Still, one of the main ones is that it provides a better developer experience by adding inline documentation, auto-completion, and type-checking, even if the consumers of our code don’t use TypeScript.

Nonetheless, folks developing libraries and frameworks sometimes want to avoid going through the friction introduced by TypeScript, such as the need to compile the code, the extra configuration needed to make it work, and the strictness of the type system. This aversion is understandable, but in this article, we’ll cover how we can get the benefits of TypeScript without switching completely.

The power of JSDocs

JSDocs is a standard for adding inline documentation to JavaScript code that predates TypeScript and was a strong influence for its type system. As such, we can use JSDocs to add inline documentation and type-checking to our code when working with vanilla JavaScript. Let’s see an example with a simple greeting function:

greet.js
1
// @ts-check
2
3
/**
4
* @param {string} name The name of the person to greet.
5
*/
6
export const greet = name => `Hello ${name}!`;

By adding a block comment that starts with /**, we can create a JSDoc block, and then we use the @param tag to type the function arguments that tools like VSCode will use to get the types the same it would do with TypeScript, but without it. Want to make that argument optional? Just wrap it in square brackets:

greet.js
1
// @ts-check
2
3
/**
4
* @param {string} [name] The name of the person to greet.
5
*/
6
export const greet = (name = "Guest") => `Hello ${name}!`;

We aren’t limited to only using primitive types such as string, but also we can create more complex types using the @typedef tag:

greet.js
1
// @ts-check
2
3
/**
4
* @typedef User
5
* @property {string} name The name of the user.
6
* @property {number} age The age of the user.
7
*/
8
9
/**
10
* @param {User} user The user to greet.
11
*/
12
export const greet = user => `Hello ${user.name}!`;

Mixing a little TypeScript

This last type declaration using @typedef might feel like it adds a lot of “noise” in our code, so one solution is to use .d.ts files next to our .js files to declare complex types such as User. Let’s say then that we put this code in a types.d.ts file:

types.d.ts
1
export type User = {
2
/** The name of the user. */
3
readonly name: string;
4
/** The age of the user. */
5
readonly age: number;
6
};

And then, the JSDoc can import the type it needs:

greet.js
1
// @ts-check
2
3
/**
4
* @param {import("./types").User} user The user to greet.
5
*/
6
export const greet = user => `Hello ${user.name}!`;

That // @ts-check is annoying

You might have noticed that we are using a // @ts-check comment in each file to enable the “power of TypeScript” on it. This comment is only necessary because we are avoiding configuration files, but then again, we can add a little TypeScript without compiling our code. Therefore, we only need one extra file in the root of our project named tsconfig.json with the following content:

tsconfig.json
1
{
2
"compilerOptions": {
3
"checkJs": true,
4
"allowJs": true
5
}
6
}

This file will ensure that TypeScript will check our JavaScript files and remove the need for the @ts-check comment. On top of that, I recommend setting the strict option to true to get the most out of TypeScript and avoid the most common pitfalls of untyped code.

Ready to publish

Our code is almost ready to give all the benefits of type checking to our consumers (either if they also use JavaScript or if they use TypeScript). We only need to generate the type files in our prepublishOnly script in package.json:

package.json
1
{
2
"scripts": {
3
"prepublishOnly": "tsc --emitDeclarationOnly"
4
}
5
}

This script will generate a .d.ts file for each .js file in our project. The last step is to add a types field to our package.json to point to the generated types:

package.json
1
{
2
"types": "index.d.ts"
3
}

And that’s it! We can now publish our code to npm, and our consumers will be able to get the benefits of TypeScript without having to switch to it.

If you want to see a real-life example, you can check out this open-source package I maintain that uses this approach to provide config files for all my other projects: configs.

Conclusion

I’m a big fan of TypeScript and use it in all my projects, but this is still a good compromise to provide the same benefits without switching completely.

The point of this article is that there’s no good excuse to avoid DX improvements, so if you have any projects out there that you know are being used by others, it may be time to make them more friendly to your consumers.