Crafting JS Applications with JSDoc and TypeScript
There are mixed feelings about using a dynamically typed language like JavaScript when building larger applications. I'm not here to fight for JavaScript over Typescript. I will though illustrate how we can write clean JavaScript and use Typescript to assist us in doing so.
When designing a system, the use of a dynamic functional language allows you to concentrate on the structure, flow and data. The designs seem to become simpler to reason about. Noise of classes, types, inheritance dissappers, and we can concentrate on what needs to be done.
Everyone has to write or read JavaScript on some point in their life so in this post I will discuss how we can use it with Typescript and JSDoc to help architect a system.
As clean developers we are trained that comments are bad and that your code should explain your intention and using comments indicates the code is not clear enough.
But not all comments are bad ;)
Enter JSDoc :- JSDoc is a markup language used to annotate JavaScript source code files.
We're not going to use JSDoc to document our code, but rather to help with code types and hence code completion and type checking.
I use Visual Studio Code, which includes built-in JavaScript IntelliSense out of the box (many other IDEs do the same). VS Code understands many standard JSDoc annotations, and uses these annotations to provide rich IntelliSense. We'll use the type information from JSDoc comments to type check your JavaScript.
Let’s start with a simple example:
1function getInfo(birthYear, name) { 2 const currentYear = new Date().getFullYear() 3 return `My name is ${name.toUpperCase()}, I am approx ${currentYear - birthYear} years old` 4}
The above code is simple: it defines a function, getInfo, that takes in a birthYear and name and returns a message. Simple JavaScript, no types, However, when calling the function we need to figure out what types the inputs are based on how they are used in the function.
In JavaScript there are 2 ways of doing this. One is to infer type be supplying defaults and the other is to use JSDoc
1function getInfo(birthYear = 0, name = "") { 2 const currentYear = new Date().getFullYear() 3 return `My name is ${name.toUpperCase()}, I am approx ${currentYear - birthYear} years old` 4}
1/** 2 * @param {number} birthYear 3 * @param {string} name 4 * @returns {string} 5 */ 6function getInfo(birthYear, name) { 7 const currentYear = new Date().getFullYear() 8 return `My name is ${name.toUpperCase()} and I am approx ${currentYear - birthYear} years old` 9}
Now when we write the calling code of this function, it's more informative
Let's do another example
1/** 2 * @param {number} birthYear 3 * @param {string} name 4 * @returns {Person} 5 */ 6function createPerson(birthYear = 0, name = "") { 7 const currentYear = new Date().getFullYear() 8 return { 9 name: name.toUpperCase(), 10 age: currentYear - birthYear 11 } 12} 13
Here we return a Person type. So where and how do we define this Person type?
We can create a types.d.ts file to keep some or all of our types. This file can be stored anywhere and named anything:
1//types.d.ts 2export interface Person { 3 name: string, 4 age: number 5}
We use the typedef import statement to import the type
1 2/**@typedef {import('./types').Person} Person */ 3 4/** 5 * @param {number} birthYear 6 * @param {string} name 7 * @returns {Person} 8 */ 9function getInfo(birthYear = 0, name = "") { 10 const currentYear = new Date().getFullYear() 11 return { 12 name: name.toUpperCase(), 13 age: currentYear - birthYear 14 } 15}
Last example. We'd like to define a Product Repository
1//types.d.ts 2export interface Product { 3 name: string, 4 price: number 5} 6 7 8export interface ProductRepository { 9 getProducts: () => Promise<Product[]> 10 getProduct: (id: number) => Promise<Product> 11 deleteProduct: (id: number) => Promise<boolean> 12 createProduct: (name: string, price: number) => Promise<Product> 13} 14 15 16export interface ProductDataSource { 17 getDBProducts: () => Promise<Product[]> 18 getDBProduct: (id: number) => Promise<Product> 19 deleteDBProduct: (id: number) => Promise<boolean> 20 createDBProduct: (name: string, price: number) => Promise<Product> 21} 22 23 24export interface NotificationService { 25 notify: (message: string) => Promise<boolean> 26}
Now let's create the implementation
1/**@typedef {import('./types').ProductRepository} ProductRepository */ 2/**@typedef {import('./types').ProductDataSource} ProductDataSource */ 3/**@typedef {import('./types').NotificationService} NotificationService */ 4 5 6/** 7 * 8 * @param {{dataSource: ProductDataSource, notificationService: NotificationService}} dependencies 9 * @returns {ProductRepository} 10 */ 11const ProductRepositoryImpl = ({ dataSource, notificationService }) => ({ 12 13 async getProducts() { 14 const result = await dataSource.getDBProducts() 15 return result 16 17 }, 18 19 async createProduct(name, price) { 20 const result = dataSource.createDBProduct(name, price); 21 await notificationService.notify("Product Created") 22 return result 23 24 }, 25 26 async deleteProduct(id) { 27 const result = dataSource.deleteDBProduct(id) 28 await notificationService.notify("Product Deleted") 29 return result 30 31 }, 32 33 34 async getProduct(id) { 35 const result = dataSource.getDBProduct(id) 36 return result 37 38 } 39 40})
Here we create a Product Repository Implementation with data source and notification service dependencies injected through the inputs. The higher-order function then returns an object of type ProductRepository
Summary
By simply providing enough JSDoc, will give us intelli-sense and type checking across all our JS files, while still keeping code clean and focused