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:
// @ts-check
/**
* @param {string} name The name of the person to greet.
*/
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:
// @ts-check
/**
* @param {string} [name] The name of the person to greet.
*/
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:
// @ts-check
/**
* @typedef User
* @property {string} name The name of the user.
* @property {number} age The age of the user.
*/
/**
* @param {User} user The user to greet.
*/
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:
export type User = {
/** The name of the user. */
readonly name: string;
/** The age of the user. */
readonly age: number;
};
And then, the JSDoc can import the type it needs:
// @ts-check
/**
* @param {import("./types").User} user The user to greet.
*/
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:
{
"compilerOptions": {
"checkJs": true,
"allowJs": true
}
}
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
:
{
"scripts": {
"prepublishOnly": "tsc --emitDeclarationOnly"
}
}
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:
{
"types": "index.d.ts"
}
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: @vangware/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.