Powering your Back-End applications with TypeScript

Darío Blanco Iturriaga

CTO @ MindDoc

April 11, 2019

The "Small Team" Problem

Web Application

  • Client-side?
  • Server-side?

Front-End and Back-End tech stack should be as small as possible

Front-End frameworks

  • SPA "monopoly"
  • Embrace JavaScript

But... JavaScript sucks

var Rectangle = function (id, x, y, width, height) {
    Shape.call(this, id, x, y);
    this.width  = width;
    this.height = height;
};
Rectangle.prototype = Object.create(Shape.prototype);
Rectangle.prototype.constructor = Rectangle;
var Circle = function (id, x, y, radius) {
    Shape.call(this, id, x, y);
    this.radius = radius;
};
Circle.prototype = Object.create(Shape.prototype);
Circle.prototype.constructor = Circle;

ECMAScript made it better

class Rectangle extends Shape {
    constructor (id, x, y, width, height) {
        super(id, x, y)
        this.width  = width
        this.height = height
    }
}
class Circle extends Shape {
    constructor (id, x, y, radius) {
        super(id, x, y)
        this.radius = radius
    }
}

Back-End should ideally have...

  • Same language (e.g. JavaScript)
  • Similar tooling (e.g. test framework)
  • Statically-typed

Oh wait... JavaScript is not statically-typed!

Let's use TypeScript everywhere

The Tech Stack

Same test framework is a great productivity win

Bootstraping Node

Basic Skeleton

Project manifest and dependencies

TypeScript root files and compiler options

TypeScript lint rules 

TypeScript source files

TypeScript Jest test specs

Jest test configuration

package.json

{
  "name": "typescript-node-starter",
  "version": "1.0.0",
  "description": "A TypeScript Node skeleton project",
  "main": "src/server.ts",
  "scripts": {
    "compile": "tsc",
    "lint": "tslint --project .",
    "test": "jest --coverage"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/minddocdev/typescript-node-starter.git"
  },
  "keywords": [
    "typescript",
    "node",
    "skeleton",
    "starter"
  ],
  "author": "development@minddoc.com",
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/minddocdev/typescript-node-starter/issues"
  },
  "homepage": "https://github.com/minddocdev/typescript-node-starter#readme",
  "devDependencies": {
    "@types/express": "^4.16.1",
    "@types/jest": "^24.0.11",
    "@types/node": "^11.13.4",
    "jest": "^24.7.1",
    "ts-jest": "^24.0.2",
    "tslint": "^5.15.0",
    "tslint-config-airbnb": "^5.11.1"
  },
  "dependencies": {
    "express": "^4.16.4",
    "typescript": "^3.4.3"
  }
}
{
  "compilerOptions": {
    "baseUrl": ".",
    "target": "es6",
    "module": "commonjs",
    "outDir": "dist",
    "sourceMap": true,
    "strict": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "lib": [
      "dom",
      "es2017"
    ],
    "skipLibCheck": true,
    "noEmit": true,
    "paths": {
      "@/*": [
        "src/*"
      ],
      "#/*": [
        "test/*"
      ]
    }
  },
  "exclude": [
    "node_modules",
  ],
  "include": [
    "src/**/*.ts",
    "test/**/*.ts",
  ]
}

tsconfig.json

tsc

{
  "defaultSeverity": "error",
  "extends": [
    "tslint-config-airbnb"
  ],
  "jsRules": {},
  "rules": {
    "import-name": false,
    "no-duplicate-variable": true
  },
  "rulesDirectory": []
}

tslint.json

tslint --project .

const path = require('path');

module.exports = {
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/server.ts',
  ],
  coverageDirectory: '<rootDir>/test/coverage',
  coverageReporters: process.env.CI ? ['text'] : ['json', 'lcov', 'text', 'clover'],
  coverageThreshold: {
    global: {
      branches: 100,
      functions: 100,
      lines: 100,
    },
  },
  globals: {
    'ts-jest': {
      tsConfig: 'tsconfig.json',
    }
  },
  moduleFileExtensions: [
    'json',
    'ts',
    'js',
  ],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '^#/(.*)$': '<rootDir>/test/$1',
  },
  rootDir: path.resolve(__dirname),
  testEnvironment: 'node',
  testMatch: [
    '**/test/**/*.spec.(ts|js)',
  ],
  transform: {
    '^.+\\.(ts|tsx)$': 'ts-jest',
  },
};

jest.config.js

jest --coverage

Running Node

  • Alternative to node (ts-node)
  • Executes TypeScript files directly
  • Source map support
  • Loads tsconfig.json automatically
  • Development version for file changes (ts-node-dev)
  • Module loading (tsconfig-paths)

tsconfig-paths

{
  "compilerOptions": {
    "baseUrl": ".",
    "target": "es6",
    "module": "commonjs",
    "outDir": "dist",
    "sourceMap": true,
    "strict": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "lib": [
      "dom",
      "es2017"
    ],
    "skipLibCheck": true,
    "noEmit": true,
    "paths": {
      "@/*": [
        "src/*"
      ],
      "#/*": [
        "test/*"
      ]
    }
  },
  "files": [
    "node_modules/@types/node/index.d.ts",
    "node_modules/@types/jest/index.d.ts",
    "node_modules/@types/express/index.d.ts"
  ],
  "exclude": [
    "node_modules",
  ],
  "include": [
    "src/**/*.ts",
    "test/**/*.ts",
  ]
}
  • Relative imports can be terrible (e.g. ../../../../myfolder/foo)
  • Arbitrary module paths are nice (e.g. @/myfolder/foo)
  • Use tsconfig-paths/register in the ts-node command to allow them
{
  "name": "typescript-node-starter",
  "version": "1.0.0",
  "description": "A TypeScript Node skeleton project",
  "main": "src/server.ts",
  "scripts": {
    "compile": "tsc",
    "dev": "ts-node-dev -r tsconfig-paths/register --ignore-watch node_modules src/server.ts",
    "lint": "tslint --project .",
    "start": "ts-node --transpile-only -r tsconfig-paths/register src/server.ts",
    "test": "jest --coverage"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/minddocdev/typescript-node-starter.git"
  },
  "keywords": [
    "typescript",
    "node",
    "skeleton",
    "starter"
  ],
  "author": "development@minddoc.com",
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/minddocdev/typescript-node-starter/issues"
  },
  "homepage": "https://github.com/minddocdev/typescript-node-starter#readme",
  "devDependencies": {
    "@types/express": "^4.16.1",
    "@types/jest": "^24.0.11",
    "@types/node": "^11.13.4",
    "jest": "^24.7.1",
    "ts-jest": "^24.0.2",
    "ts-node-dev": "^1.0.0-pre.32",
    "tslint": "^5.15.0",
    "tslint-config-airbnb": "^5.11.1"
  },
  "dependencies": {
    "express": "^4.16.4",
    "ts-node": "^8.0.3",
    "tsconfig-paths": "^3.8.0",
    "typescript": "^3.4.3"
  }
}

package.json

ts-node-dev -r tsconfig-paths/register --ignore-watch node_modules src/server.ts

  • Reloads server on file changes (except node_modules)
  • Registers tsconfig compiler paths
# ------------------------------------------------------
#                       Dockerfile
# ------------------------------------------------------
# image:    typescript-node-starter
# tag:      latest
# name:     minddocdev/typescript-node-starter
# repo:
# how-to:   docker build -t minddocdev/typescript-node-starter:latest .
# Requires: node:alpine
# authors:  development@minddoc.com
# ------------------------------------------------------

FROM node:11.13-alpine
LABEL maintainer="development@minddoc.com"

# Create app directory and install production dependencies
WORKDIR /usr/src/app
COPY tsconfig.json package.json package-lock.json ./
RUN npm install --only=production

# Copy src files (Use .dockerignore to exclude non essential)
COPY src/ ./src

# Set permissions for the node user
RUN chown -R node:node .
USER node

# Run ts-node with src/server.ts entry point
CMD ["npm", "start"]

Dockerfile

{
  "name": "typescript-node-starter",
  "version": "1.0.0",
  "description": "A TypeScript Node skeleton project",
  "main": "src/server.ts",
  "scripts": {
    "compile": "tsc",
    "dev": "ts-node-dev -r tsconfig-paths/register --ignore-watch node_modules src/server.ts",
    "docker": "docker build -t minddocdev/typescript-node-starter:latest . && docker run -it minddocdev/typescript-node-starter:latest",
    "lint": "tslint --project .",
    "start": "ts-node --transpile-only -r tsconfig-paths/register src/server.ts",
    "test": "jest --coverage"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/minddocdev/typescript-node-starter.git"
  },
  "keywords": [
    "typescript",
    "node",
    "skeleton",
    "starter"
  ],
  "author": "development@minddoc.com",
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/minddocdev/typescript-node-starter/issues"
  },
  "homepage": "https://github.com/minddocdev/typescript-node-starter#readme",
  "devDependencies": {
    "@types/express": "^4.16.1",
    "@types/jest": "^24.0.11",
    "@types/node": "^11.13.4",
    "jest": "^24.7.1",
    "ts-jest": "^24.0.2",
    "ts-node-dev": "^1.0.0-pre.32",
    "tslint": "^5.15.0",
    "tslint-config-airbnb": "^5.11.1"
  },
  "dependencies": {
    "express": "^4.16.4",
    "ts-node": "^8.0.3",
    "tsconfig-paths": "^3.8.0",
    "typescript": "^3.4.3"
  }
}

package.json

Production app!

Create your types

Working with types

  • TypeScript automatically includes node_modules/@types packages by default
  • node_modules/@types are devDependencies.
  • Automatic inclusion is only important for global declarations
  • You can always use import { ExternalType } from 'external-lib';
{
  "compilerOptions": {
    "baseUrl": ".",
    "target": "es6",
    "module": "commonjs",
    "outDir": "dist",
    "sourceMap": true,
    "strict": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "lib": [
      "dom",
      "es2017"
    ],
    "skipLibCheck": true,
    "noEmit": true,
    "paths": {
      "@/*": [
        "src/*"
      ],
      "#/*": [
        "test/*"
      ]
    },
    "typeRoots": [
      "types"
    ],
    "types": [
      "dependencies",
      "mytypes"
    ]
  },
  "files": [
    "node_modules/@types/node/index.d.ts",
    "node_modules/@types/jest/index.d.ts",
    "node_modules/@types/express/index.d.ts"
  ],
  "exclude": [
    "node_modules",
  ],
  "include": [
    "src/**/*.ts",
    "test/**/*.ts",
    "types/**/*.d.ts"
  ]
}

tsconfig.json

Only packages under ./types are global

Only specific packages from ./types will be global (optional)

Set specific node_modules types as global

import * as express from 'express';

export const expressApp = express();

/* istanbul ignore next */
expressApp.get('/', (req, res) => {
  const response: HelloWorldResponse = { message: 'Hello World!' };
  res.send(response);
});

/**
 * Start the express application.
 *
 * @param port The port to listen to.
 */
export function start(port = 3000) {
  expressApp.listen(port, (err: any) => {
    if (err) {
      console.error(`Unable to start app. Found error: ${err.message}`);
      return;
    }
    console.info(`Example app listening on port ${port}!`);
  });
}
// Type package for own global type definitions
interface HelloWorldResponse {
  message: string;
}

src/app.ts

types/mytypes/index.d.ts

No need to import HelloWorldResponse

tsc will complain

What next?

As we grow, there are new challenges

  • Shared types
  • Configuration duplication
  • Common dependencies

Scaling into Back-End microservices makes this worse

Shared types

  • Front-End and Back-End share same types (e.g. API requests and responses)
  • Initial solution: internal @types package used as dependency

More repository overhead

Configuration duplication

  • tsconfig.json, tslint.json, jest.config.js, .gitlab-ci.yml are the same or very similar in our TypeScript projects
  • Initial solution: copied files, which is bad

Common dependencies

  • Update bot creates a lot of duplicated MRs in different repos
  • Internal packages require more repos, duplication and overhead

Monorepo

The solution

Monorepo

  • Only for TypeScript projects
  • Lerna handles multiple packages
  • Lerna hoists common dependencies
  • Gitlab CI triggers specific pipelines based on file changes
  • Gitlab CI avoids config duplication via YAML Anchors

@minddoc

@darioblanco

@minddocdev

@darioblanco

https://stackoverflow.com/jobs/companies/minddoc-by-schoen-clinic

https://github.com/minddocdev/typescript-node-starter

Powering your Back-End applications with TypeScript

By Darío Blanco

Powering your Back-End applications with TypeScript

  • 253