NodeJS and TypeScript - up1/workshop-springboot-nodejs-go GitHub Wiki

Workshop NodeJS and TypeScript

1. Setup project with TypeScript

Install TypeScript as Global

$npm install -g typescript

Create Project

$mkdir demo-rest-ts
$cd demo-rest-ts

$npm init -y

Install TypeScript in NodeJS

$npm install --save-dev typescript ts-node

Initial TypeScript

$tsc --init

Install express and express's type

$npm install -S express
$npm install -D @types/express

2. Create Server with TypeScript

File server.ts

import express, { Request, Response } from "express";
const app = express();
const port = 3000;

app.get("/", (req: Request, res: Response) => {
  res.send("Hello World!");
});

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});

Run

$tcs
$node dist/server.js

Config file package.json

"scripts": {
    "dev": "ts-node server.ts",
    "start": "rm -rf ./dist && tsc --outDir ./dist && node ./dist/index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  }

Or run with dev mode

$npm run dev 

Or run with production mode

$npm run start

3. Convert to OOP

import express, { Application, Request, Response } from "express";

class Server {
  private app: Application;
  private port: number;

  constructor(port: number = 3000) {
    this.app = express();
    this.app.use(express.json());
    this.app.use(express.urlencoded({ extended: true }));
    this.port = port;
    this.setupRoutes();
  }

  private setupRoutes(): void {
    this.app.get("/", this.handleRoot.bind(this));
  }

  private handleRoot(req: Request, res: Response): void {
    res.status(200).json({ message: "Hello World!" });
  }

  public start(): void {
    this.app.listen(this.port, () => {
      console.log(`Example app listening on port ${this.port}`);
    });
  }
}

// Create and start the server
const server = new Server();
server.start();

4. Add Product controller or router

  • Get product by id
  • Create a new product

File product.ts

import { Application, Request, Response } from "express";

export class Product {
  private app: Application;

  constructor(app: Application) {
    this.app = app;
    this.setupRoutes();
  }

  private setupRoutes(): void {
    this.app.get("/product/:id", this.getProductById.bind(this));
    this.app.post("/product", this.createProduct.bind(this));
  }

  private createProduct(req: Request, res: Response): void {
    const newProduct = req.body;
    res.status(201).json({ 
        message: "Product created successfully",
        product: newProduct
    });
  }
  
  private getProductById(req: Request, res: Response): void {
    const productId = req.params.id;
    res.status(200).json({ 
        id: productId,
        name: "Sample Product",
        price: 100.50,
        description: "This is a sample product description."
    });
  }
}

Add product to File server.ts

...
constructor(port = 3000) {
    this.app = express();
    this.app.use(express.json());
    this.app.use(express.urlencoded({ extended: true }));
    this.port = port;
    this.setupRoutes();
    new Product(this.app);
  }
...

5. Add test to project

  • Jest
  • SuperTest

Install

$npm install --save-dev jest supertest @types/jest @types/supertest ts-jest

Create file jest.config.js

module.exports = {
  preset: "ts-jest",
  testEnvironment: "node",
  testMatch: ["**/*.test.ts"],
  collectCoverage: true,
  coverageDirectory: "coverage",
  collectCoverageFrom: ["**/*.ts", "!**/*.d.ts"],
};

Edit file package.json

"scripts": {
    ...
    "test": "jest"
  }

Run test

$npm test

Create test case in file __tests__/product.test.ts

import request from 'supertest';
import express, { Application } from 'express';
import { Product } from '../product';

describe('Express API', () => {
  let app: Application;
  
  beforeEach(() => {
    // Create a fresh express app for each test
    app = express();
    app.use(express.json()); // Add middleware to parse JSON bodies
    
    // Set up routes directly instead of using Server class to avoid actual server listening
    app.get('/', (req, res) => {
      res.status(200).json({ message: 'Hello World!' });
    });
    
    // Initialize Product routes
    new Product(app);
  });
  
  describe('Root endpoint', () => {
    it('should return Hello World message', async () => {
      const response = await request(app).get('/');
      
      expect(response.status).toBe(200);
      expect(response.body).toEqual({ message: 'Hello World!' });
    });
  });
  
  describe('Product endpoints', () => {
    it('should get a product by ID', async () => {
      const productId = '123';
      const response = await request(app).get(`/product/${productId}`);
      
      expect(response.status).toBe(200);
      expect(response.body).toEqual({
        id: productId,
        name: 'Sample Product',
        price: 100.50,
        description: 'This is a sample product description.'
      });
    });
    
    it('should create a new product', async () => {
      const newProduct = {
        name: 'New Product',
        price: 199.99,
        description: 'A brand new product'
      };
      
      const response = await request(app)
        .post('/product')
        .send(newProduct)
        .set('Content-Type', 'application/json');
      
      expect(response.status).toBe(201);
      expect(response.body).toEqual({
        message: 'Product created successfully',
        product: newProduct
      });
    });
  });
});

6. Working with PostgreSQL

$npm install joi
$npm install pg sequelize-typescript
$npm install sequelize reflect-metadata sequelize-typescript

$npm install --save-dev @types/pg
$npm install --save-dev @types/node @types/validator

6.1 Create file .env

POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USERNAME=user01
POSTGRES_PASSWORD=password01
POSTGRES_DATABASE_NAME=demodb

REDIS_HOST=localhost
REDIS_PORT=6379

6.2 Edit file tsconfig.json

"experimentalDecorators": true, 
"emitDecoratorMetadata": true,

6.3 Create file database.ts

// Working with postgresql and sequelize
import { Sequelize } from "sequelize-typescript";
import { MyProduct } from "./models/product_model";

export class Database {
  private static instance: Database;
  private sequelize: Sequelize;

  private constructor() {
    this.sequelize = new Sequelize({
      database: process.env.POSTGRES_DATABASE_NAME,
      dialect: "postgres",
      host: process.env.POSTGRES_HOST,
      password: process.env.POSTGRES_PASSWORD,
      username: process.env.POSTGRES_USERNAME,
      models: [MyProduct], //  your models
    });
    this.sequelize.sync({ force: true });
  }

  public static getInstance(): Database {
    if (!Database.instance) {
      Database.instance = new Database();
    }
    return Database.instance;
  }

  public getSequelize(): Sequelize {
    return this.sequelize;
  }
}

6.4 Create model file product_model.ts

import { Table, Column, Model } from 'sequelize-typescript';

@Table({
  tableName: 'demo_product',
})
export class MyProduct extends Model {

  @Column({
    type: 'varchar',
    allowNull: false,
  })
  name!: string;

  @Column({
    type: 'varchar',
    allowNull: true,
  })
  code?: string;

  @Column({
    type: 'integer',
    allowNull: false,
  })
  quantity!: number;

  @Column({
    type: 'float',
    allowNull: false,
  })
  price!: number;


}

6.5 Edit file product.ts

import { Application, Request, Response } from "express";
import Joi from "joi";
import { Database } from "./database";
import { MyProduct } from "./models/product_model";

export class Product {
  private app: Application;
  private database: Database;

  constructor(app: Application) {
    this.app = app;
    this.database = Database.getInstance();
    this.setupRoutes();
  }

  private setupRoutes(): void {
    this.app.get("/product/:id", this.getProductById.bind(this));
    this.app.post("/product", this.createProduct.bind(this));
  }

  private async createProduct(req: Request, res: Response): Promise<void> {
    const newProduct = req.body;
    // Check if the request body has the correct data types
    if (!req.is('application/json')) {
      res.status(400).json({ error: 'Invalid content type' });
      return;
    }

    // Validate the request body with joi
    const productSchema = Joi.object({
      name: Joi.string().required(),
      code: Joi.string().required(),
      price: Joi.number().required(),
      quantity: Joi.number().required(),
    });
    const { error } = productSchema.validate(newProduct);
    if (error) {
      console.error("Validation error:", error.details[0].message);
      res.status(400).json({
        message: "Validation error",
        error: error.details[0].message,
      });
      return;
    }

    console.log("Creating product:", newProduct);
    const product = new MyProduct({
      name: newProduct.name,
      code: newProduct.code,
      price: newProduct.price,
      quantity: newProduct.quantity,
    });
    try {
      const savedProduct = await product.save();
      console.log("Product created successfully.");
      res.status(201).json({
        id: savedProduct.id,
        name: savedProduct.name,
        code: savedProduct.code,
        price: savedProduct.price,
        quantity: savedProduct.quantity,
      });
    } catch (error: any) {
      console.error("Error creating product:", error);
      res.status(500).json({
        message: "Error creating product",
        error: error.message,
      });
    }
  }

  private async getProductById(req: Request, res: Response): Promise<void> {
    const productId = req.params.id;

    const product = await MyProduct.findByPk(productId);
    if (!product) {
      console.log("Product not found");
      res.status(404).json({ message: "Product not found" });
      return;
    }
    console.log("Product found:", product);

    res.status(200).json({
      id: product.id,
      name: product.name,
      price: product.price,
      quantity: product.quantity,
    });
  }
}

7. Add ESLint

$npm install --save-dev eslint @eslint/js typescript-eslint

Create file eslint.config.mjs

// @ts-check

import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  tseslint.configs.recommended,
);

Custom rules

// @ts-check

import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
    eslint.configs.recommended,
    tseslint.configs.strict,
    tseslint.configs.stylistic,
    // add any additional rules or overrides here
    {
        rules: {
            'no-console': 'warn',
            'no-unused-vars': 'warn',
            'quotes': ['error', 'single'],
            'semi': ['error', 'always'],
            // Add more rules as needed
        },
    }
  );

Edit file package.json

"lint": "eslint '**/*.ts'"

Run

$npm run lint

8. Structured log in nodejs

Use pino-http with express

$npm install pino-http

Edit file server.ts

import express, { Application, Request, Response } from "express";
import { Product } from "./product";

import logger from "pino-http";

export class Server {
  private app: Application;
  private port: number;

  constructor(port: number = 3000) {
    this.app = express();
    this.app.use(express.json());
    this.app.use(express.urlencoded({ extended: true }));

    this.app.use(logger());

    this.port = port;
    this.setupRoutes();
  }

  ...

  private handleRoot(req: Request, res: Response): void {
    req.log.info({key: "value"}, "Demo structured log");
    res.status(200).json({ message: "Hello World!" });
  }
  ...
}
⚠️ **GitHub.com Fallback** ⚠️