Modern JavaScript, TypeScript and You #2
Replies: 1 comment 3 replies
-
Thank you for this writing. The node/js ecosystem is indeed way too complicated. I will save this step-by-step to recommend for people just getting into node. Btw, have you already checked Deno? It tackles the key issues that you described. I recommend it if you have the time; I'm sure you will like it (no transpiler, no bundler, no bikeshedding, no npm, no yarn, a sane binary that does everything you expect it to). The Deno ecosystem is not as mature, but I wish it was (or at least that it had an AdonisJS-like framework) because IMO it makes the js/ts experience butter smooth. As smooth as if it was rust or like if node "was launched yesterday." |
Beta Was this translation helpful? Give feedback.
-
JavaScript community is always on a treadmill. The spec, tooling, frameworks, best practices, in fact everything is on the road to change.
I have been trying very hard to stay away from all the hype for the past few years. And follow the path of simplicity. Web development is not rocket science, and everything is achievable by staying on the simplest path.
Anyways, the main topic of this post is to talk about the state of modern JavaScript and TypeScript.
Imagine JavaScript was launched yesterday
JavaScript has come a long way. It started as a tiny scripting language that turned into a full-blown programming language.
We got introduced to transpilers, bundling, complex toolchains, and whatnot during this time. All that maybe was required in the past to push the language forward and not wait forever for the spec to finalize and then get implemented by different runtimes.
But, using complex toolchains came with a downside. They modified our behavior and now we do not double think about adding Babel, WebPack, Vite, and 100 other build tools to our projects. It has become the second nature for us.
There is a cost to using complex build tools, which is the massive complexity they come with. This is why it is an ever-evolving space because no one is happy with the current style of doing this. Once grunt was incredible, then it was gulp, then wow WebPack, and now Vite.
These tools are great. I am not ranting on them. But I want to look at JavaScript with fresh eyes and find the simplest way to use it every day.
Also, I primarily on Node.js to write backend servers. So, the post is purely focused on that.
So let's see how we can use the greatness of modern JavaScript and some of the surrounding tools like TypeScript and ESLint.
In case you are not aware, node.js support all the following features.
Getting started
Install the LTS version of Node (i.e.
16.14.2
as of today).Create an empty folder and run
npm init --yes
inside it. This command will create apackage.json
file for you.Let's open
package.json
and add a new propertytype=module
inside it. It tells Node.js that you want to use ES modules.Now, let's create two new files inside the
src
directory.Export a dummy user object from the
src/user.js
file.And import it inside the
src/greet.js
file to greet the user. Notice that we import the module with the extension. If you omit the extension, the import will break. That's a breaking change when moving from CJS to ESM.Finally, run the code as
node src/greet.js
.❯ node src/greet.js Hello "virk". We are using ES modules
This is the simplest and best possible way to use ES modules. "Breath of fresh air 😌"
Import aliases
Right now, our directory structure is quite simple. But in an actual application, we may have nested folders and import statements across sub-directories.
In the past, you might have used WebPack aliases or AdonisJS aliases, and most recently Vite aliases.
If you are new to aliases, they solve a simple use case.
Instead of importing files as follows
You can import them as follows (@modules is the alias)
Now, Node.js has built-in support for defining aliases. They are known as subpath imports. You define them within the
package.json
file.Once you have defined the subpath import, you can import the same
./user.js
file as follows. Notice that we have removed the.js
extension from the import statement this time. Because it has been handled by the alias expression./src/*.js
.Subpath exports
The consumer of Node packages (distributed as npm packages) are used to import modules by their path. Following is an example directory structure of a package.
The
index.js
is the "main" file. You can import it directly using the package name.However, if
src/colors.js
was never exposed from theindex.js
file. You can still import it by defining the complete path.This is good and bad at the same time. Your package consumers can pull in any module they want. However, it makes it harder for you to re-arrange the code internally without breaking others apps.
Usually, it is considered good practice to define the public interface for a package explicitly. Just like classes have public and private properties (yes, JavaScript has them as well, stop making Fun of it 🤪).
With the help of subpath exports you can control which files to expose from a package. For example, if you want to export the
index.js
file, write the following code line within thepackage.json
file.Maybe, you also want to expose the
src/colors.js
file. But still want to retain the ability to move the file somewhere later during some re-factor. Here's how you can do that.And then import it as follows. Notice that we are not defining the file extension since it is part of the subpath export expression
./src/colors.js
.Adding TypeScript to the mix
You just heard about the new language (JavaScript) yesterday, and now someone told you, wait, you can add static types to this language using a new language called TypeScript.
So
TypeScript - Types = Valid JavaScript
. This is the theory and the goal of TypeScript. Later in this post, we will explore how TypeScript holds to this goal.I kind of agree with this sentiment, but not completely. So here's my take on TypeScript.
Considering this. I will be making it possible to write AdonisJS apps in pure JavaScript. I will continue to use TypeScript because of the reasons mentioned above in point 3.
However, JavaScript has matured a lot, and we should not force TypeScript on new developers.
Alright, a lot of theory. Let's see how well TypeScript plays with modern JS constructs.
Install TypeScript 4.7, which is in beta right now. This version of TypeScript has decent support for ESM
Create a
tsconfig.json
file with the following contents. Make sure themodule=NodeNext
for ESM and TypeScript work great together.Finally, let's rename all the
.js
files to.ts
. As per our previous example, we will have to rename the following files.Let's compile the code using TypeScript and see if we get any errors. The compiled code is written within the
./build
folder.For me, the code compiles fine. However, I cannot run it from the build folder.
node ./build/src/greet.js # Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/xx/xxxx/src/user.js' imported from '/xx/xxxx/src/greet.js'
The issue is that our subpath import
"#src/*": "./src/*.js"
is trying to find thesrc/user.js
file within the root of the project and not the build folder.Suppose we update the subpath import expression to always lookup inside the
build
directory. Then, our TypeScript code will not compile because, as per the TypeScript compiler, there is no./build/src/user.js
file.In short, I have not been able to use subpath imports and TypeScript together. The only way to make it work is to use relative paths inside import statements.
Let's open the
src/greet.ts
file and fix it for now.Wait, have to notice something???
Even though we are using TypeScript, we will have to import our files with the
.js
extension. Yes, this is how it has to be.TypeScript justifies the decision by saying, "Our goal is to make
TypeScript - Types = JavaScript
" and if they allow.ts
extension, they will diverge from their goal.From the DX point of view, this is a wrong move. I cannot imagine teaching a newcomer to import a file with the
.js
extension, even though they see the file has a.ts
extension in front of their eyes.But, I still buy the TypeScript argument. Because this goal will help JavaScript in the long run, meaning, TypeScript will not re-invent the ES spec.
TypeScript and JavaScript private members are not the same
Until recently, JavaScript had no concept of public and private class properties. As a result, everything you defined in a class was publicly accessible.
TypeScript, on the other hand, does have
private
,public
, andprotected
modifiers. The following TypeScript codeCompiles to the following JavaScript code. At runtime, anyone can access the
firstName
andlastName
properties.However, as of today, you can have private class properties in JavaScript. The properties starting with a
#
are marked as private. This privacy is also maintained during the runtime.The TypeScript
private
modifier does not create a JavaScript private property.Remember TypeScript's original goal?
TypeScript - Types = JavaScript
. So, if they start transforming theprivate
modifier to#
, they will be going against their initial goal.Therefore, even when using TypeScript, you will have to use
#
and not theprivate
modifier if you want a runtime private property. That kind of makes this modifier redundant in the first place.Right now, I extensively make use of TypeScript modifiers. However, I will be getting rid of them altogether in my code. Every property will be public by default unless prefixed with a
#
(which gives both the compile-time and runtime safety)Running TypeScript without compilation
No one has the patience to re-compile the code every time before they can run it. Also, the TypeScript compiler is not the fastest, so it can become frustrating to wait for 5-10 seconds before you can see the output of your change.
Therefore, many tools are trying to run TypeScript code without the compilation step. They take the TypeScript code and return back JavaScript without performing type-checking.
Like everything else in JavaScript, this space has also become crowded. So I will not talk about all those tools and their differences. Instead, our goal is to pick the most straightforward tool that does the job well in a predictable manner.
Say hello to
ts-node
So far, TS node is the best tool that sticks to a single goal of compiling TypeScript to JavaScript within memory. Following are some of the things I like about the Ts node.
tsconfig.json
rules for compiling the code. Many other tools introduce additional configuration options, making the entire space more confusing and error-prone.Let's install the Ts node as follows
And run your TypeScript code using its ESM loader. The
--loader
flag uses the Node.js experimental loader hooks to compile the source code on the fly.Output. For now, you will have to live with the following warning statement since the loaders API is in the experimental stage.
Great, but it's not fast
By default, Ts Node performs type checking. I turn it off because I rely on my code editor to do that. Also, I perform type checking when bundling my code for production vs. doing it every time during development.
You can turn off type checking by adding the following property to the
tsconfig.json
file.You can run TypeScript code without compiling or type-checking it. Type checking is left on the editor during development, and you must perform it during the production build.
How about even faster?
I have seen people re-writing their apps because the new framework is 30% faster than they are using. Yeah, we all are insane and craving faster, faster, and faster tools.
Usually, tools optimizing for speed comprises somewhere else. Something they usually do not document and something you find after using that tool in the long run.
However, sometimes the speed is achieved by simply using a faster language. This is the case with swc. TypeScript official compiler is written in JavaScript (one huge file), and SWC is written in Rust. No doubt, Rust is a better language for writing compilers.
Let's start by installing the following SWC packages.
And adding the following property to the
tsconfig.json
file.Yeah. It's freaking fast!
SWC caveats
There are a few things that make me a little uncomfortable with SWC.
The above points can bring some headaches in the future. For example, SWC behaves a little differently from TypeScript on certain features, and then your code will work fine in development. However, it may break in production (if you make the production build using tsc).
I am happy using SWC via Ts-node because if SWC is not something I need in the future, I can just remove one flag and continue using Ts-node with the official TypeScript compiler.
Adding ESLint to the mix
Thankfully, we all have settled on ESLint for linting our code. Feel free to use any ESLint settings you want (as per your taste). However, I recommend following plugins and rules for a better ESM + TypeScript experience.
@typescript-eslint/parser
for TypeScript projects.unicorn/prefer-module
disallows globals available in CJS only (like__dirname
). It also disallowsrequire
,exports
andmodule.exports
.unicorn/prefer-node-protocol
enforces you to use thenode:
prefix for native modules. For exampleimport { readFile } from 'node:fs'
Conclusion
Not sure about you. But I find the JavaScript ecosystem very confusing. We are adding layers over layers over layers.
I know this culture will not change sooner. However, JavaScript as a language has matured a lot, and Node.js implements all of the ES features.
Except for TypeScript, I want to keep my development toolchain simple and easy to work with.
Footnotes
transpileOnly
example does not work right now. I have started a discussion on the same in the Ts Node repo.Beta Was this translation helpful? Give feedback.
All reactions