6 years ago, when we tried NodeJS for a large-scale project in TiTrias for the first time. We were not sold yet. And we continued suggesting Laravel as the go to backend stack, unless something requires Java or .NET stack. And as always, Angular for frontend. Since the beginning of 2022, we started using and suggesting Typescript for backend (Specifically Nestjs). In this post, We will explain all the reasons why we are taking this decision. And why we think Typescript is a huge leap in how we build complex apps.
What this post isn’t about?
- It’s almost always misleading when someone talks about performance in a language! Most slow webapps aren’t slow because of the tech stack, but because of poor code practices and bad architecture. So we are not including speed tests or theoretical benchmarks to give points to typescript or node. All languages are slow if the code is architected incorrectly.
- This post isn’t about how typescript code is cleaner! Any decent developer can produce great clean code using any tool if they have the needed knowledge
- We are not saying Typescript is our prefered language and hence it will work for everything. It DOESN’T work for everything. However, it simply now has a much bigger footprint than any other programming language on what it’s capable of doing in all fields it can cover.
- This post is not about saying Typescript is better than everything else. It’s about why Typescript is working FOR US better than all.
- Finally, This post isn’t about small projects, but bigger ones (Think multi-teams on multiple products on the same codebase)
Disclaimer: This post is highly opinionated, all disagreements and counter arguments are most welcome.
Our Prefered Stack
- Nx to manage a monorepo with frontend, backend, E2E testing, CI/CD as well as tools in one place
- NestJS for backend
- Sequelize as ORM
- Graphql/Rest for API design
- Angular for frontend
- Storybook for UI building and testing
- Jest/Cypress for testing
Before introducing typings, it wasn’t possible to define a concrete data representation. For example in DB models, Graphql queries/mutations I/O, REST API I/O, etc. Shareable data representation feels natural in Typescript. You can write the data representation once and keep extending while using the great typescript operators to ensure type safety.
For example, define your DB model, extend it to create the Graphql Objects. Then take the graphql objects and add some stuff maybe to create a form representation on the frontend, then take the same interface/type. Go one more step and add some utility types operators to do great types transformation automatically (Without the need to keep redefining types). You can partial it to create a search form where all fields are optional. Then you can omit some parameters to create a quick update DTO. Finally, make a read-only type for quick safety mechanism to make sure developers can’t redefine values for the type. Even better, explore Generics for complicated reusable types, like pagination, search results, etc.
Sharing types between frontend and backend
That’s where nothing can compete with typescript! Thanks to the three points above, You can basically share the dtos and the interfaces between frontend and backend out of the box. All the REST requests/responses and GraphQL Queries/Mutations, including changes and errors can be easily caught while developing instead of while running E2E testing or even worse on run time. THIS IS HUGE!
Before we switched to Typescript, it was very easy to miss sharing some changes to an API request. For example: someone changed some parameter name in the backend from id to uid, pushed the code and it passed all backend unit testing however it failed while doing the integration testing on the PR guards on the CI side. As the developer is only PHP backend developer, they didn’t understand what’s the problem on frontend. So they passed the problem to the frontend guy waiting for a fix. A complete waste of time, without a direct way to get the error while coding.
We know there are some possible ways to generate typings for request/response dtos via any API documentation tool like Swagger. Or even generate Graphql types for frontend from any schema. With typescript across the board, the possibilities are limitless and you can basically detect any small issue on coding time. This is specially true if the frontend/backend teams are using the same monorepo.
Because now you are using the same language (And possibly libraries) between the backend and frontend, using a monorepo starts to seem like a no-brainer. Where you have all your code, all your testing stuff (Unit testing, Integration testing, E2E testing, etc) as well as your CI/CD pipeline logic if needed. This is brilliant. A monorepo is technically possible even if backend and frontend don’t share the same language, but when everything shares the same language, you can take the monorepo one more step and share a lot of business logic in libraries to be reusable for frontend and backend as.well as typings. Earlier you can do the same by publishing the libraries and re-importing in the apps, which is slower and would need extra steps for automating.
Our choice here is Nx, a very powerful tool to manage monorepos, works great with Angular and Nest. To continue on the shareable types between frontend and backend, once we jump to a new project, the first library we create is an API library, where we share all request/response dtos or Graphql Schema and it’s typescript representation (Via code generator) and then whenever backend and frontend needs to introduce a change, it’s shared out of the box, super safe.
And due to the idea you can have all your code base in one place. It’s much easier to build a highly portable repo.
This is our favorite item on the list. Portability in Typescript has to do with a lot of things, monorepos is definitely one of them. but also the idea you can have multiple running versions of node on the same machine at the same time without any hassles. And the idea you don’t need a server to run that (Like Apache/Nginx/Tomcat, etc). You can do the same on PHP, Java or .NET, but it’s harder to maintain and extend without dockers, as you might need servers, runtime environments, development kits installed for each version you want to support.
Because it’s a monorepo, you usually only need to run yarn or npm once and that’s it. Worst case scenario you will install the version meant for the repo using any node version manager (Like nvm, stores the value in .nvmrc), and that’s it! Then the amazing package manager will take care of the rest.
Someone might jump in and say we can solve all of that by building containers/dockers to keep versions consistent and make code as portable as possible, which is 100% correct! However, if we are going to use something out of the box without docker, we would pick that everyday of the week. As at some point with a huge number of docker containers running while developing, docker will start to be a problem and you might need to keep starting stopping dockers based on the projects you currently work on. For smaller projects, that’s def not a big deal. Docker can basically convert any code to become fully portable no matter what stack you are using. However, even with docker, building a docker for a Typescript is easier than most. As again you don’t need a server layer until you start thinking about deployment or reverse proxies.
Another point related to portability is development environment portability, a single runtime like ts-node, alongside the minimal number of files you are required to maintain that’s not actually code, this is a bit tricky part so let’s take a quick example:
In Nest/Node, you need to create only a package.json and tsconfig and most probably that’s it. You are good to go once you have the runtime. However, with most other languages/frameworks, you will need much more complicated setup with a ton of generated files just to get started. And here we don’t mean the boilerplating needed for clean architecture like MVC for example, which is great for clean coding, but we mean the generated files needed by the IDE, or needed by internal libraries or the development kit for the language you are using.
Portability wouldn’t be possible if Nodejs wasn’t on it’s own a runtime, which made it a server by default that can serve both dynamic and static content.
As Node is a runtime already, you don’t need any server to run Nodejs code, for small projects, node alone can be enough to host your deployed code. However, 90% of the time this is not enough. For that you would need a real web server layer like Nginx to host your code. Setting up either of those setup is very easy for Node, as the runtime itself has all required powers to act as a standalone server for serving static and dynamic content.
If you don’t like that, you can rely on pm2 to build a cluster in one command, or you can even build supservisor setup, or have a full-fledged setup with dedicated webserver, reverse proxy, caching and CDN.
PHP leads without any chance of competition. Thanks to cPanel and shared hosting, PHP host-ability is not just really easy, but very cheap for small projects. However, for large projects, hosting pain is almost the same. You will always need orchestrator and containers, and the pretty thing here is Nodejs footprint for small projects is great. And for large apps, Node (And nest) has a bunch of tools to build and pack the code in different ways.
For example, if you want to extract specific functionality as a serverless function, packing it with webpack is great and would give you a small single file to use, where on other languages this might not be as easy. and it’s harder to mess up something while deployment simply because of code portability and testability as a single JS file that only needs Node as runtime out, even if the serverless code is extracted from a very complicated monorepo hosting more projects.
But what if when you are trying your code before hosting, you are stuck with a complex bug? You will definitely need to debug. Another very easy task!
Debugging in NodeJS/Typescript is very easy. Whereas in other interpreted languages or compiled languages, it is a bit harder or much harder depending on the language you are using.
To debug Typescript NodeJS code, even if you have never used Node before on the machine, is very easy, you just need these steps:
- Clone code locally
- Install deps (by running yarn)
- Run code in debug mode to open a debugging port locally or on a remote server
- Attach a debugger to a running task (Even if it’s remote)
and that’s it, very fast and efficient and supported by all IDEs/Editors quite easily.
Where on the other hand on PHP for example, you need a more complicated setup, as PHP on -it’s own- can’t just allow an IDE to attach a debugging session to a running task, you need XDebug installed for that and configured. Although with the help of docker to add XDebug directly, it can become much easier.
For Java and .NET, as those two use Common Intermediate Language/MSIL or bytecode representation to run the code (On JVM for Java or dot NET runtime/Mono for .NET), you need more tools to allow debugging a piece of code.
The extra steps would depend on if you want to run the code locally and debug it or debug remote code, but in short you would need to install runtime environment, development kits, and anything else you might need based on the language and the setup (JDK/JRE/Mono/.NET runtime) and if you need to run the code locally or not.
Again, although modern IDEs and editors along with the help of containers can help facilitating this flow, it’s way easier on Node/typescript side.
Yarn is great! one of the best package managers to date! It can compete with composer and beat it quite easily. The way yarn always work is due to how it uses deterministicity and lock files to make sure it will always work for everyone. Yarn is the youngest package manager out there and feels fast, with native support of workspaces/submodules so you can use it to manage your monorepos directly. Quite unique on that side.
Comparing that to .NET and Java, the number of libraries isn’t in Node favor, but it’s getting there.
What makes it even easier to write not just fast performing code but clean one, is the available frameworks.
When we tried NodeJS for the first time, we had a problem finding a decent framework to compete with other languages giants, like Laravel on PHP, Spring for Java, or .NET for ASP/C#. But now, due to the number of great emerging frameworks like NestJS, Koa, or even Express with Types support. It’s really easy to find a stack that works for you, whether it’s websockets, RESTful API, MVC, template support or even event-driven architecture. You will find great solutions to support you.
With that being said, NodeJS has a huge competition with .NET, Java EE and PHP, there is no laravel-level of completeness framework on Node side yet, there is no WordPress-level widespread framework that’s easy to use with the same number of themes and plugins. There is no spring-level powerful framework. So, hands-down, this point is the hardest one if you are going to do the switch, you need to be very careful of what you need, to make sure you can switch without a lot of pain.
But for us, Nestjs with Sequelize provides a great stack that can supports many use cases. Mentioning Sequelize, let’s get a quick peek into DB ORM solutions.
This is a tricky part. ORM is a fundamental part of any good backend framework. If we are talking NoSQL, Typescript is great!. The best actually, as it allows you to write documents reducers using very similar language in both Mongo or Couch, and if you want to go with mongoose or monk, that’s a match in heaven.
However, if we are talking SQL, NodeJS is way behind! Almost all other ORMs can easily beat NodeJS’s any day of the week. Just compare between EF for .NET, Hibernate for Java, Eloquent/Doctrine for Laravel/PHP vs TypeORM, Sequelize (Our choice for now) and Prisma (Great idea but lacking a LOT) and all Typescript libraries can be easily ranked the last.
On that side, although scopes, traits (via Mixins), repositories, associations and may more concepts of any good enough ORM exist in Sequelize, but they aren’t as well documented or as mature as the other options. For example, writing a clean type-safe definition, yet shareable and extendable mixin in Sequelize for complex operations can be super hard or even impossible in some cases.
If this problem of good SQL ORM, but not the best ORM is a big one, the easiness of having a full stack on a single code base might make up for that problem.
Easiness of having full Stack
First of all, We are not saying you can’t be a full stack unless you use Typescript, what We are saying is it’s much easier having Typescript knowledge and applying it to frontend and backend.
Then taking this full-stack ready knowledge to a cross-functional scrum team, is amazing! Nothing can beat that. Having backend developers and frontend developers within the team is still possible and great. But having a couple of full-stacks make a lot of tasks easier. And Typescript closes a major gap for frontends or backends to be full stack.
Being a full stack is not just a Language knowledge, however from development stand point, being able to change an API on the backend then jump to change the interface on the frontend is very helpful instead of having two people working on that, specially if your code organization is smart enough to have the API dtos shared between the frontend and backend. Not just API DTOs but also Websocket events.
What is great in Node is, it’s a runtime daemon on it’s own, not like legacy PHP (mod_php) where each request has its own process lifespan. This is beneficial for a lot of things, one of those is supporting websockets out of the box
That’s not the only reason why Websockets rock in Nodejs. But also having SocketIO support on typescript on frontend and backend is super strong. SocketIO is one of the most mature libraries for websockets. Having the DTOs of all websocket events typed for both frontend and backend is something extremely important. So whenever something changes, you don’t get screwed on the run-time but you can see the type errors while developing or changing any event/message definition.
With that being said, there are two points to keep in mind:
- Both .NET and Java are great on Websockets side, you would lose the ability to share event DTOs between frontend and backend but you might get benefits on other sides.
Jest and Cypress combo is great. We can’t say it’s unbeatable, because JUnit is great, PHP Unit is fine, and dotnet test is cool. However, Jest and Cypress have two of the best fluent APIs among all. This alongside having the ability to share data mocks everywhere, is yet another appreciated feature.
When we start a new project, We write a data sample only once per entity. ONLY ONCE. and use it everywhere that needs mock data, for example:
- Database data seeders
- Backend unit testing that mocks models
- Frontend unit testing to mock form submissions and business logic
- UI testing to mock data bindings and component rendering
When you have a lot of entities, the idea of sharing mock data feels great. A lot less re-writing between frontend and backend
This post shows clearly a lot of advantages of a relatively young programming language (Typescript), with some serious lacking points that needs you to be ] careful before you decide to do the switch. If those problems aren’t an issue for you, do the switch and it might be one of the best decisions for your next project.
Let us know what do you think, what are the best/worst parts about Typescript? Which libraries/frameworks do you use the most in your Typescript ecosystem?