TypeScript and the Second Coming of Node
Long before Node.js famously entered the backend development scene, the tech industry had experienced several evolutions of the "Next Big Thing" with both successful and failed patterns and techniques.
In this article, we look at some of the shortcomings of Node.js programming models and how TypeScript offers the hope of increasing the legitimacy of Node.js ecosystem in the enterprise by bringing back some storied and successful programming paradigms from the past.
A Brief History Lesson
In the '90s, Java was in with the promise that C/C++ developers would no longer be burdened with low-level memory management and could write once, run everywhere. Schools quickly adopted Java into the core curriculum and many companies started migrating legacy applications to Java.
In the late '00s, many framework competitors to the Java platform emerged: Microsoft pushed various robust toolchains with C#, ASP.NET, dependency injection, etc. Ruby on Rails gained rapid popularity, in part due to the ability of its ActiveRecord library to simplify the traditional complexity of RDBMS integrations. The common theme across these and others was that the emerging frameworks made developing applications more efficient and canonical, at the typical cost of steep learning curves to become proficient.
Node.js entered the scene with a super-lean, yet capable core series of libraries that brought JavaScript and Chrome's V8 runtime to server-side applications. Most notably, it provided a full HTTP interface (http
) that was quickly extended by web application frameworks like the ever-popular Express.js as well as other contenders like Hapi.js.
A full, functional HTTP application could be written with as lean and simple a code snippet as:
const app = new Express(); app.use('/', (req, res, next) => { res.send('Hello World!'); }
That was it, we were off to the races! Developers started building APIs but then the word spread and suddenly everyone from startups to Fortune 500s were writing Node.js apps. The flexibility of the JavaScript language and widespread preexisting developer experience (in the browser) enabled a large and growing developer community to quickly produce performant, exciting, modern web applications.
Yet, in some ways, it could be argued that this was the last interesting thing to happen in Nodeland. Web applications developers are still using mostly Express.js today with broad patterns that haven't really evolved. Sure, we ported a lot of existing libraries over from other languages, but the language and programming patterns have remained relatively stagnant.
For example, how many times have Express.js developers seen this?
const v1Router = new express.Router(); v1Router.use('/auth', authController); v1Router.use('/item', itemController); v1Router.use('/item/search', searchController); v1Router.use('/bar', barController); v1Router.use('/bar/baz', someOtherController); item.get('/', item.getIndex); item.get('/:id', item.get) item.post('/', item.create) item.put('/:id', item.update) item.get('/search', searchController) someOtherController.use('/', moreNestedRouters); app.use('/', apiV1Router); app.use('/', apiV2Router);
While a simple example, the complexity of this pattern becomes more difficult to maintain as the application grows. While it functionally succeeds in allowing Express.js to map incoming requests to handlers, the pattern is essentially hand-rolled for every endpoint with a single dumping ground for all imported handlers.
Enter TypeScript
TypeScript, created by Microsoft, brings compile-time type checking built atop of JavaScript. Among the many interesting features of the language is support for decorators and dependency-injection frameworks from years past (à la C#, Spring, etc.). Features like these may allow startups and enterprises alike to scale JavaScript projects more effectively than their existing capabilities.
By reimplementing core features from past enterprise frameworks, some may argue that we're reinventing the wheel and throwing out everything that attracted us to Node.js in the first place!
Mature frameworks allow developers to accomplish the following:
- Allow increased boilerplate / setup costs if it speeds adding business value to the overall application
- Focus more on logic than application configuration
- Get to market faster
Decorators
Decorators have been around for a long time and help solve the router fatigue pattern from above. For example InversifyJS has a companion library express-utils that exposes Express.js bindings. This example uses decorators to tell the framework how to discover and map controllers to routers.
@controller('/item') class ItemController { constructor( @inject("ItemService") private itemService: ItemService ) {} @httpGet('/') private index( req: express.Request, res: express.Response, next: express.NextFunction ): string { return itemService.getAll(); } @httpGet('/:id') private getItem( @requestParam('id') id: string, req: express.Request, res: express.Response, next: express.NextFunction ) { return itemService.getOne(id); } }
Another emerging framework is Nest.js that provides a much more opinionated view of how server-side apps should be built. Take Angular, marry it with Express.js or Fastify, and you've got the foundations for a clean and concise app. The patterns we saw with Inversify can be seen here, but at its core, Nest.js uses modules to describe functional areas of an app. Each module can contain a controller, DTO, service, etc. that's specific to that component. This allows for plug-and-play (via dependency-injection) between modules.
The above snippet might be realized with the following code in Nest.js:
@Controller('item') export class ItemsController { constructor(private readonly itemService: ItemService) {} @Get() findAll(@Req() request) { return itemService.getAll(); } @Get() findAll(@Param('id') id) { return itemService.getOne(id); } }
Check out the docs for a deeper dive on the architecture. Nest.js feels like there's a lot going for it and we're excited to see what the future roadmap holds.
Dependency Injection
As we saw above, decorators allow the developer to give hints to the tooling about their intent. Tooling that combines decorators with dependency-injection frameworks can even further reduce the mental overhead and tedium of setting up applications.
In a regular Express.js app, you may have many layers of nested modules, services, and database connections. The onus is on the developer to lay out the application in a convenient way to support a workflow.
Example:
const setup = () => { const config = getConfig(); const dbConnection = getDbConnection(config.db); const myService = SomeService(config, dbConnection); const router = MyController(myService); app.use('/', router); }
With the Nest.js framework, a lot of this boilerplate can be simplified by decorating the implementation:
import { Injectable, Controller } from '@nestjs/common'; @Injectable() class DatabaseConnection { constructor(private readonly config: DbConfig) { // initialization } } @Injectable() class SomeService { constructor( private readonly dbConnection: DatabaseConnection ) { // initialization } } @Controller() class MyController { constructor(private readonly theService: SomeService) {} }
As an application scales, this technique provides an easy way to inject dependencies without the manual setup we see above.
JS_{on}_ Fatigue
When I first made the leap from Java/C++ to Node.js, I remember how natural writing JSON felt and how easily it integrated into the ecosystem. However, over the years I've become frustrated by how much configuration JavaScript/Express.js apps typically require.
Sequelize vs. Sequelize-TypeScript
For example, take Sequelize — a battle-tested SQL ORM. It allows developers to define objects and interfaces for SQL tables:
const Task = sequelize.define('task', { name: Sequelize.STRING, birthday: Sequelize.DATE, }); Task.hasMany(Hobby, { as: 'hobbies' });
Below is the same code using sequelize-typescript, which realizes decorators for Sequelize primitives:
import { Table, Column, Model, HasMany } from 'sequelize-typescript'; @Table class Person extends Model<Person> { @Column name: string; @Column birthday: Date; @HasMany(() => Hobby) hobbies: Hobby[]; }
While simple, the latter example is a lot cleaner and easier for me to reason. All the information is inline in the class and we don't have to look outside to find other relationships (e.g. hasMany
). As more columns are added, the benefits are even greater.
JOI vs. class-validator
https://github.com/hapijs/joi#example
const Joi = require('joi'); const schema = Joi.object().keys({ username: Joi.string().alphanum().min(3).max(30).required(), password: Joi.string().regex(/^[a-zA-Z0-9]{3,30}$/), access_token: [Joi.string(), Joi.number()], birthyear: Joi.number().integer().min(1900).max(2013), email: Joi.string().email({ minDomainAtoms: 2 }) });
https://github.com/typestack/class-validator
export class User { @isAlphanumeric() username: string; @Matches(/^[a-zA-Z0-9]{3,30}$/) password: string; @isNumberString() accessToken:string @min(1900) @max(2013) birthyear: number @IsEmail() email: string }
Again, with typing we have a class containing inline validation all in one place. Which do you prefer?
Tooling
Large software projects often require tooling to aid developers. Some developers may consider the need for code completion, hints, etc. as signs of a poorly designed system. And while this may be true for contained codebases, the enterprise generally has different concerns crossing many parts of the organization.
Types can be considered a form of documentation to bridge the gap between teams. They're explicit and leave no ambiguity to the developer.
Sure, JavaScript gives you flexibility:
// options.bar does not exist function(options) { if (options.foo) { doFoo(); } else if (options.bar) { doBar(); } // no error! }
However, there's no hint to the developer as to what is expected from options and it can change without warning, leading to failures.
function(options: Config) { // developer knows the type of params to expect if (options.foo) { doFoo(); } else if (options.bar) { doBar(); } // compile-time error! }
The addition of TypeScript support as a first-class feature in many editors (JetBrains, VSCode, etc.) brings a level of legitimacy for enterprise-focused teams. With tooling and explicit typing, enterprise developers will have a similar experience to Eclipse or Visual Studio environments, which have been staples in the industry for years.
Why TypeScript When Other Statically Typed Languages Exist?
JavaScript has become ubiquitous in cloud computing and is often the first language supported by new platform features like AWS lambda. Google Cloud Functions is another example, which has been in beta since 2016 and only supports JavaScript with Python support lagging behind.
Furthermore, new server-side TypeScript runtimes are already in the works. The creator of Node.js, Ryan Dahl, has started Deno, which promises a TypeScript runtime atop V8. This is definitely a project to watch over the next year.
Forward Looking
In this post, we reviewed some powerful TypeScript constructs that bring a new level of productivity and ergonomics to the often-fatigued JavaScript developer. With a host of tooling and explicit APIs we are able to scale JavaScript beyond simple projects. All in all, we're very excited to see how projects like Deno, VSCode, and Nest.js bring Node.js to a new generation of backend developers.