├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .vscode └── extensions.json ├── README.md ├── api ├── .eslintrc.json ├── jest.config.ts ├── project.json ├── src │ ├── main.ts │ ├── modules │ │ └── user │ │ │ ├── index.ts │ │ │ ├── user.bootstrap.ts │ │ │ ├── user.controller.ts │ │ │ ├── user.model.ts │ │ │ ├── user.repository.ts │ │ │ └── user.route.ts │ └── root-routes.ts ├── tsconfig.app.json ├── tsconfig.json └── tsconfig.spec.json ├── core ├── .eslintrc.json ├── README.md ├── jest.config.ts ├── project.json ├── src │ ├── base-controller.ts │ ├── database.ts │ ├── errors.ts │ ├── index.ts │ ├── middlewares │ │ ├── global-error-handler.ts │ │ └── index.ts │ ├── response.ts │ ├── router.ts │ ├── typed-route.ts │ └── types.ts ├── tsconfig.json ├── tsconfig.lib.json └── tsconfig.spec.json ├── data └── users.json ├── jest.config.ts ├── jest.preset.js ├── nx.json ├── package.json ├── pnpm-lock.yaml └── tsconfig.base.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["**/*"], 4 | "plugins": ["@nx"], 5 | "overrides": [ 6 | { 7 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 8 | "rules": { 9 | "@nx/enforce-module-boundaries": [ 10 | "error", 11 | { 12 | "enforceBuildableLibDependency": true, 13 | "allow": [], 14 | "depConstraints": [ 15 | { 16 | "sourceTag": "*", 17 | "onlyDependOnLibsWithTags": ["*"] 18 | } 19 | ] 20 | } 21 | ] 22 | } 23 | }, 24 | { 25 | "files": ["*.ts", "*.tsx"], 26 | "extends": ["plugin:@nx/typescript"], 27 | "rules": {} 28 | }, 29 | { 30 | "files": ["*.js", "*.jsx"], 31 | "extends": ["plugin:@nx/javascript"], 32 | "rules": {} 33 | }, 34 | { 35 | "files": ["*.spec.ts", "*.spec.tsx", "*.spec.js", "*.spec.jsx"], 36 | "env": { 37 | "jest": true 38 | }, 39 | "rules": {} 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | dist 5 | tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | 37 | # System Files 38 | .DS_Store 39 | Thumbs.db 40 | 41 | .nx/cache -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | strict-peer-dependencies=false 2 | auto-install-peers=true 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | /dist 3 | /coverage 4 | /.nx/cache -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 120 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "nrwl.angular-console", 4 | "esbenp.prettier-vscode", 5 | "firsttris.vscode-jest-runner" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TypeScript Crash Course 2 | 3 | ## Introduction 4 | 5 | Recently, I've been preparing a private course to teach TypeScript as a crash course within the company. I plan to release recorded videos of the course eventually. 6 | 7 | The course will start by writing an Express Node.js application from scratch and then gradually refactor it to make it more DRY (Don't Repeat Yourself) and type-safe. 8 | 9 | Without TypeScript, refactoring can become quite tedious and less enjoyable. I believe this course will provide a great learning experience for those interested in TypeScript. 10 | 11 | The target audience for this course is expected to have a background in .NET C# and/or React, with a rough split of 60/40. We won't spend too much time on Object-Oriented Programming (OOP) concepts, as the audience should already be familiar with them. 12 | 13 | The main goal of the course is to teach how to build type-safe backend APIs, validate requests, and implement a global error handler (eliminating the need for try-catch blocks in controller handlers). 14 | 15 | Stay tuned, as I'll be sharing portions of the course (although not covering end-to-end type-safety, as that would be too in-depth for this crash course). 16 | 17 | ## Teaching Checkpoints 18 | 19 | You can check out different branches corresponding to the teaching checkpoints as follows: 20 | 21 | 1. `backend-starter` - Setting up a TypeScript project with a monorepo structure using Nx. 22 | 2. `backend-phase-1` - Writing a basic Express API for CRUD operations on user data, using a simple JSON file as the database. 23 | 3. `backend-phase-2-global-error-and-response` - Ensuring consistent response models across routes and implementing a global error handler middleware to handle errors and provide consistent error responses. 24 | 4. `backend-phase-3-typed-route` - Validating requests (query, params, and body) using the Zod library to achieve type-safety at compile-time and runtime. A helper function is created to generate type-safe route endpoints. 25 | 26 | ## Course Outcome 27 | 28 | By the end of the course, your code will be much cleaner because the controllers will handle data validation and provide type-safety when writing controller routes. Additionally, you won't need to handle try-catch blocks for each route separately, and you'll be able to auto-register routes with Express. 29 | 30 | Example of writing a controller: 31 | 32 | ```ts 33 | export class UserController extends BaseController { 34 | constructor(protected userRepository: UserRepository) { 35 | super(); 36 | } 37 | 38 | /** 39 | * Create a new user 40 | */ 41 | create = route 42 | .post('/') 43 | .body( 44 | z.object({ 45 | username: z.string(), 46 | email: z.string().email(), 47 | password: z.string(), 48 | }) 49 | ) 50 | .handler(async ({ body }) => { 51 | await this.userRepository.create(body); 52 | return { 53 | message: 'User created successfully', 54 | }; 55 | }); 56 | } 57 | ``` 58 | 59 | Example of registering routes: 60 | 61 | You can register all routes in a controller like this: 62 | 63 | ```ts 64 | const app = express(); 65 | app.use('/users', new Router().registerClassRoutes(userController).instance); 66 | ``` 67 | 68 | ## Nx Manual 69 | 70 | ### How to create a new project 71 | 72 | ```bash 73 | npm add --global nx@latest 74 | pnpx create-nx-workspace@latest tscc --preset=ts 75 | ``` 76 | 77 | ### How to create a new node application 78 | 79 | ```bash 80 | nx add @nx/node 81 | nx g @nx/node:application api 82 | ``` 83 | 84 | ### How to create a new library 85 | 86 | ```bash 87 | nx g @nx/node:lib core 88 | ``` 89 | -------------------------------------------------------------------------------- /api/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /api/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'api', 4 | preset: '../jest.preset.js', 5 | testEnvironment: 'node', 6 | transform: { 7 | '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], 8 | }, 9 | moduleFileExtensions: ['ts', 'js', 'html'], 10 | coverageDirectory: '../coverage/api', 11 | }; 12 | -------------------------------------------------------------------------------- /api/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "$schema": "../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "api/src", 5 | "projectType": "application", 6 | "targets": { 7 | "build": { 8 | "executor": "@nx/esbuild:esbuild", 9 | "outputs": ["{options.outputPath}"], 10 | "defaultConfiguration": "production", 11 | "options": { 12 | "platform": "node", 13 | "outputPath": "dist/api", 14 | "format": ["cjs"], 15 | "bundle": false, 16 | "main": "api/src/main.ts", 17 | "tsConfig": "api/tsconfig.app.json", 18 | "assets": ["api/src/assets"], 19 | "generatePackageJson": true, 20 | "esbuildOptions": { 21 | "sourcemap": true, 22 | "outExtension": { 23 | ".js": ".js" 24 | } 25 | } 26 | }, 27 | "configurations": { 28 | "development": {}, 29 | "production": { 30 | "esbuildOptions": { 31 | "sourcemap": false, 32 | "outExtension": { 33 | ".js": ".js" 34 | } 35 | } 36 | } 37 | } 38 | }, 39 | "serve": { 40 | "executor": "@nx/js:node", 41 | "defaultConfiguration": "development", 42 | "options": { 43 | "buildTarget": "api:build" 44 | }, 45 | "configurations": { 46 | "development": { 47 | "buildTarget": "api:build:development" 48 | }, 49 | "production": { 50 | "buildTarget": "api:build:production" 51 | } 52 | } 53 | } 54 | }, 55 | "tags": [] 56 | } 57 | -------------------------------------------------------------------------------- /api/src/main.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import morgan from 'morgan'; 3 | import routes from './root-routes'; 4 | import { globalErrorHanlder } from '@tscc/core'; 5 | 6 | const host = process.env.HOST ?? 'localhost'; 7 | const port = process.env.PORT ? Number(process.env.PORT) : 3000; 8 | 9 | const app = express(); 10 | 11 | // parse json request body 12 | app.use(express.json()); 13 | 14 | // parse urlencoded request body 15 | app.use(express.urlencoded({ extended: true })); 16 | 17 | app.use(morgan('dev')); 18 | 19 | app.use('/', routes); 20 | 21 | app.use(globalErrorHanlder); 22 | 23 | app.listen(port, host, () => { 24 | console.log(`[ ready ] http://${host}:${port}`); 25 | }); 26 | -------------------------------------------------------------------------------- /api/src/modules/user/index.ts: -------------------------------------------------------------------------------- 1 | export { default as userRoutes } from './user.route'; 2 | -------------------------------------------------------------------------------- /api/src/modules/user/user.bootstrap.ts: -------------------------------------------------------------------------------- 1 | import { UserController } from './user.controller'; 2 | import { UserRepository } from './user.repository'; 3 | import { UserModel } from './user.model'; 4 | import { v4 as uuidv4 } from 'uuid'; 5 | import { Database, TypedRoute } from '@tscc/core'; 6 | 7 | export const route = new TypedRoute(); 8 | const db = new Database('users', { 9 | defaultData: [ 10 | { 11 | id: uuidv4(), 12 | username: 'firstuser', 13 | password: 'password', 14 | email: 'first@email.com', 15 | }, 16 | ], 17 | }); 18 | const userRepository = new UserRepository(db); 19 | export const userController = new UserController(userRepository); 20 | -------------------------------------------------------------------------------- /api/src/modules/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { UserRepository } from './user.repository'; 2 | import { BaseController } from '@tscc/core'; 3 | import { route } from './user.bootstrap'; 4 | import { z } from 'zod'; 5 | 6 | export class UserController extends BaseController { 7 | constructor(protected userRepository: UserRepository) { 8 | super(); 9 | } 10 | /** 11 | * Read a list of users 12 | */ 13 | getAll = route.get('/').handler(async () => { 14 | return { 15 | data: await this.userRepository.getAll(), 16 | }; 17 | }); 18 | 19 | /** 20 | * Read a single user 21 | */ 22 | get = route 23 | .get('/:id') 24 | .params(z.object({ id: z.string() })) 25 | .handler(async ({ params }) => { 26 | const data = await this.userRepository.get(params.id); 27 | if(!data) throw new Error('User not found'); 28 | return { 29 | data, 30 | }; 31 | }); 32 | 33 | /** 34 | * Create a new user 35 | */ 36 | create = route 37 | .post('/') 38 | .body(z.object({ 39 | username: z.string(), 40 | email: z.string().email(), 41 | password: z.string(), 42 | })) 43 | .handler(async ({ body }) => { 44 | await this.userRepository.create(body); 45 | return { 46 | message: 'User created successfully', 47 | }; 48 | }); 49 | 50 | /** 51 | * Update a user 52 | */ 53 | update = route 54 | .put('/:id') 55 | .params(z.object({ id: z.string() })) 56 | .body(z.object({ 57 | username: z.string(), 58 | email: z.string().email(), 59 | password: z.string(), 60 | })) 61 | .handler(async ({ params, body }) => { 62 | await this.userRepository.update({ 63 | ...body, 64 | id: params.id, 65 | }); 66 | return { 67 | message: 'User updated successfully', 68 | }; 69 | }); 70 | 71 | /** 72 | * Delete a user 73 | */ 74 | delete = route 75 | .delete('/:id') 76 | .params(z.object({ id: z.string() })) 77 | .handler(async ({ params }) => { 78 | await this.userRepository.delete(params.id); 79 | return { 80 | message: 'User deleted successfully', 81 | }; 82 | }); 83 | } 84 | -------------------------------------------------------------------------------- /api/src/modules/user/user.model.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface UserModel { 3 | id: string; 4 | username: string; 5 | password: string; 6 | email: string; 7 | } 8 | -------------------------------------------------------------------------------- /api/src/modules/user/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { UserModel } from './user.model'; 2 | import { Database } from '@tscc/core'; 3 | 4 | export class UserRepository { 5 | constructor(protected db: Database) {} 6 | 7 | async getAll() { 8 | return this.db.readAll(); 9 | } 10 | 11 | async get(id: string) { 12 | return this.db.read(id); 13 | } 14 | 15 | async create(input: Omit) { 16 | return this.db.insert(input); 17 | } 18 | 19 | async update(input: UserModel) { 20 | return this.db.update(input); 21 | } 22 | 23 | async delete(id: string) { 24 | return this.db.delete(id); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /api/src/modules/user/user.route.ts: -------------------------------------------------------------------------------- 1 | import { userController } from './user.bootstrap'; 2 | import { Router } from '@tscc/core'; 3 | 4 | export default new Router().registerClassRoutes(userController).instance; 5 | -------------------------------------------------------------------------------- /api/src/root-routes.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from 'express'; 2 | import express from 'express'; 3 | import { userRoutes } from './modules/user'; 4 | 5 | const router = express.Router(); 6 | 7 | router.use('/users', userRoutes); 8 | 9 | router.use('/', (req: Request, res: Response) => { 10 | res.json({ message: 'Home' }); 11 | }); 12 | 13 | export default router; 14 | -------------------------------------------------------------------------------- /api/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["node"] 7 | }, 8 | "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], 9 | "include": ["src/**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.app.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | } 12 | ], 13 | "compilerOptions": { 14 | "esModuleInterop": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /api/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "jest.config.ts", 10 | "src/**/*.test.ts", 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /core/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /core/README.md: -------------------------------------------------------------------------------- 1 | # core 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test core` to execute the unit tests via [Jest](https://jestjs.io). 8 | -------------------------------------------------------------------------------- /core/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'core', 4 | preset: '../jest.preset.js', 5 | testEnvironment: 'node', 6 | transform: { 7 | '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], 8 | }, 9 | moduleFileExtensions: ['ts', 'js', 'html'], 10 | coverageDirectory: '../coverage/core', 11 | }; 12 | -------------------------------------------------------------------------------- /core/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "core", 3 | "$schema": "../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "core/src", 5 | "projectType": "library", 6 | "targets": {}, 7 | "tags": [] 8 | } 9 | -------------------------------------------------------------------------------- /core/src/base-controller.ts: -------------------------------------------------------------------------------- 1 | import autoBind from 'auto-bind'; 2 | 3 | export abstract class BaseController { 4 | constructor() { 5 | /** 6 | * Using `autoBind` to bind all methods to the instance of the class 7 | * No need to use `.bind(this)` in the constructor 8 | */ 9 | autoBind(this); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /core/src/database.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import fsSync from 'fs'; 3 | import path from 'path'; 4 | import { v4 as uuidv4 } from 'uuid'; 5 | 6 | const databasePrefix = 'data'; 7 | 8 | export class Database { 9 | private databasePath: string; 10 | constructor( 11 | collectionName: string, 12 | protected options?: { defaultData?: Entity[] } 13 | ) { 14 | this.databasePath = path.join(databasePrefix, collectionName + '.json'); 15 | } 16 | 17 | async init() { 18 | const defaultData = this.options?.defaultData ?? []; 19 | if (!fsSync.existsSync(this.databasePath)) { 20 | await fs.mkdir(databasePrefix, { recursive: true }); 21 | await fs.writeFile( 22 | this.databasePath, 23 | JSON.stringify(defaultData, null, 2), 24 | 'utf8' 25 | ); 26 | } 27 | return this; 28 | } 29 | 30 | async readAll() { 31 | await this.init(); 32 | const data = await fs.readFile(this.databasePath, 'utf-8'); 33 | return JSON.parse(data) as Entity[]; 34 | } 35 | 36 | async read(id: string) { 37 | const data = await this.readAll(); 38 | return data.find((item) => item.id === id); 39 | } 40 | 41 | async update(input: Entity) { 42 | const data = await this.readAll(); 43 | const index = data.findIndex((item) => item.id === input.id); 44 | data[index] = { 45 | ...data[index], 46 | ...input 47 | } as Entity; 48 | await fs.writeFile(this.databasePath, JSON.stringify(data, null, 2)); 49 | } 50 | 51 | async delete(id: string) { 52 | const data = await this.readAll(); 53 | const index = data.findIndex((item) => item.id === id); 54 | data.splice(index, 1); 55 | await fs.writeFile(this.databasePath, JSON.stringify(data, null, 2)); 56 | } 57 | 58 | async insert(input: Omit) { 59 | const data = await this.readAll(); 60 | // Add a new change 61 | data.push({ 62 | ...input, 63 | id: uuidv4() 64 | } as Entity); 65 | await fs.writeFile(this.databasePath, JSON.stringify(data, null, 2)); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /core/src/errors.ts: -------------------------------------------------------------------------------- 1 | export class HttpError extends Error { 2 | constructor(public statusCode: number, public message: string) { 3 | super(message); 4 | this.name = 'HttpError'; 5 | } 6 | } 7 | 8 | export class ValidationError extends HttpError { 9 | constructor(public message: string) { 10 | super(400, message); 11 | this.name = 'ValidationError'; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './database'; 2 | export * from './base-controller'; 3 | export * from './errors'; 4 | export * from './middlewares'; 5 | export * from './response'; 6 | export * from './router'; 7 | export * from './typed-route'; 8 | -------------------------------------------------------------------------------- /core/src/middlewares/global-error-handler.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { HttpError } from '../errors'; 3 | import { BaseResponse } from '../response'; 4 | 5 | export function globalErrorHanlder( 6 | error: unknown, 7 | request: Request, 8 | response: Response, 9 | next: NextFunction 10 | ) { 11 | let statusCode = 500; 12 | let message = ''; 13 | 14 | if (error instanceof HttpError) { 15 | statusCode = error.statusCode; 16 | } 17 | 18 | if (error instanceof Error) { 19 | console.log(`${error.name}: ${error.message}`); 20 | message = error.message; 21 | } else { 22 | console.log('Unknown error'); 23 | message = `An unknown error occurred, ${String(error)}`; 24 | } 25 | 26 | response.status(statusCode).send({ 27 | message, 28 | success: false, 29 | data: null, 30 | traceStack: process.env.NODE_ENV === 'development' && error instanceof Error ? error.stack : undefined, 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /core/src/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from './global-error-handler'; 2 | -------------------------------------------------------------------------------- /core/src/response.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface BaseResponse { 3 | message?: string; 4 | /** 5 | * @default true 6 | */ 7 | success?: boolean; 8 | data?: T; 9 | traceStack?: string; 10 | } 11 | -------------------------------------------------------------------------------- /core/src/router.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, NextFunction } from 'express'; 2 | import express from 'express'; 3 | import { BaseResponse } from './response'; 4 | import { MaybePromise } from './types'; 5 | import { HandlerMetadata } from './typed-route'; 6 | 7 | export type RequestHandler = (req: Request, res: Response, next: NextFunction) => MaybePromise; 8 | 9 | export const catchAsync = (fn: (...args: any[]) => any) => (req: Request, res: Response, next: NextFunction) => { 10 | Promise.resolve(fn(req, res, next)).catch((err) => next(err)); 11 | }; 12 | 13 | export class Router { 14 | constructor(public readonly instance: express.Router = express.Router()) {} 15 | 16 | private extractHandlers(handlers: RequestHandler[]) { 17 | const handler = handlers[handlers.length - 1]; 18 | const middlewares = handlers.slice(0, handlers.length - 1); 19 | return { handler, middlewares }; 20 | } 21 | 22 | private preRequest(handler: RequestHandler) { 23 | const invokeHandler = async (req: Request, res: Response, next: NextFunction) => { 24 | const result = await handler(req, res, next); 25 | return res.send({ 26 | success: true, 27 | message: 'Request successful', 28 | ...result, 29 | } satisfies BaseResponse); 30 | }; 31 | return catchAsync(invokeHandler); 32 | } 33 | 34 | get(path: string, ...handlers: RequestHandler[]) { 35 | const { handler, middlewares } = this.extractHandlers(handlers); 36 | this.instance.route(path).get(middlewares, this.preRequest(handler)); 37 | } 38 | 39 | post(path: string, ...handlers: RequestHandler[]) { 40 | const { handler, middlewares } = this.extractHandlers(handlers); 41 | this.instance.route(path).post(middlewares, this.preRequest(handler)); 42 | } 43 | 44 | put(path: string, ...handlers: RequestHandler[]) { 45 | const { handler, middlewares } = this.extractHandlers(handlers); 46 | this.instance.route(path).put(middlewares, this.preRequest(handler)); 47 | } 48 | 49 | delete(path: string, ...handlers: RequestHandler[]) { 50 | const { handler, middlewares } = this.extractHandlers(handlers); 51 | this.instance.route(path).delete(middlewares, this.preRequest(handler)); 52 | } 53 | 54 | registerClassRoutes(classInstance: object) { 55 | const fields = Object.values(classInstance); 56 | fields.forEach((field) => { 57 | const route = field as HandlerMetadata; 58 | if (route.__handlerMetadata) { 59 | const { path, handler } = route; 60 | const method = route.method.toLowerCase(); 61 | console.log('Registering route', method, path); 62 | (this.instance.route(path) as any)[method](this.preRequest(handler)); 63 | } 64 | }); 65 | return this; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /core/src/typed-route.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { BaseResponse } from './response'; 3 | import { MaybePromise } from './types'; 4 | import { Request, Response } from 'express'; 5 | import { RequestHandler } from './router'; 6 | import { ValidationError } from './errors'; 7 | import { fromZodError } from 'zod-validation-error'; 8 | 9 | export type TypedHandler< 10 | TQuery extends z.ZodTypeAny, 11 | TParams extends z.ZodTypeAny, 12 | TBody extends z.ZodTypeAny, 13 | TResponse extends BaseResponse = BaseResponse 14 | > = (context: { 15 | query: z.infer; 16 | params: z.infer; 17 | body: z.infer; 18 | req: Request, any, z.infer, z.infer>; 19 | res: Response; 20 | }) => MaybePromise; 21 | 22 | export interface HandlerMetadata { 23 | __handlerMetadata: true; 24 | method: string; 25 | path: string; 26 | handler: RequestHandler; 27 | } 28 | 29 | export class TypedRoute { 30 | get(path: string) { 31 | return new TypedRouteHandler(path, 'get'); 32 | } 33 | 34 | post(path: string) { 35 | return new TypedRouteHandler(path, 'post'); 36 | } 37 | 38 | put(path: string) { 39 | return new TypedRouteHandler(path, 'put'); 40 | } 41 | 42 | delete(path: string) { 43 | return new TypedRouteHandler(path, 'delete'); 44 | } 45 | } 46 | 47 | export class TypedRouteHandler< 48 | RouteQuery extends z.ZodTypeAny, 49 | RouteParams extends z.ZodTypeAny, 50 | RouteBody extends z.ZodTypeAny 51 | > { 52 | schema: { 53 | query?: z.ZodTypeAny; 54 | params?: z.ZodTypeAny; 55 | body?: z.ZodTypeAny; 56 | } = {}; 57 | 58 | constructor(public readonly path: string, public readonly method: string) {} 59 | 60 | query(schema: Query) { 61 | this.schema.query = schema; 62 | return this as unknown as TypedRouteHandler; 63 | } 64 | 65 | params(schema: Params) { 66 | this.schema.params = schema; 67 | return this as unknown as TypedRouteHandler; 68 | } 69 | 70 | body(schema: Body) { 71 | this.schema.body = schema; 72 | return this as unknown as TypedRouteHandler; 73 | } 74 | 75 | handler(handler: TypedHandler): HandlerMetadata { 76 | const invokeHandler = async (req: Request, res: Response) => { 77 | let message = ''; 78 | let query; 79 | let params; 80 | let body; 81 | try { 82 | message = 'Query'; 83 | query = this.schema.query ? this.schema.query.parse(req.query) : undefined; 84 | message = 'Params'; 85 | params = this.schema.params ? this.schema.params.parse(req.params) : undefined; 86 | message = 'Body'; 87 | body = this.schema.body ? this.schema.body.parse(req.body) : undefined; 88 | } catch (error: unknown) { 89 | if (error instanceof z.ZodError) { 90 | const validationError = fromZodError(error); 91 | throw new ValidationError(`${message} ${validationError.toString()}`); 92 | } 93 | } 94 | return handler({ query, params, body, req, res }); 95 | }; 96 | return { 97 | method: this.method, 98 | path: this.path, 99 | handler: invokeHandler, 100 | __handlerMetadata: true, 101 | }; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /core/src/types.ts: -------------------------------------------------------------------------------- 1 | export type MaybePromise = T | Promise; 2 | -------------------------------------------------------------------------------- /core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | }, 6 | "files": [], 7 | "include": [], 8 | "references": [ 9 | { 10 | "path": "./tsconfig.lib.json" 11 | }, 12 | { 13 | "path": "./tsconfig.spec.json" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /core/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "../dist/out-tsc", 6 | "declaration": true, 7 | "esModuleInterop": true, 8 | "types": ["node"] 9 | }, 10 | "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], 11 | "include": ["src/**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /core/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "jest.config.ts", 10 | "src/**/*.test.ts", 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /data/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "username": "Nola_Jaskolski", 4 | "password": "sVJMaMEnQMmt22a", 5 | "email": "George_Ferry94@yahoo.com", 6 | "id": "b23febb0-2579-4e72-a7a6-38e1716e9ce4" 7 | } 8 | ] -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { getJestProjects } from '@nx/jest'; 2 | 3 | export default { 4 | projects: getJestProjects(), 5 | }; 6 | -------------------------------------------------------------------------------- /jest.preset.js: -------------------------------------------------------------------------------- 1 | const nxPreset = require('@nx/jest/preset').default; 2 | 3 | module.exports = { ...nxPreset }; 4 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/nx/schemas/nx-schema.json", 3 | "namedInputs": { 4 | "default": ["{projectRoot}/**/*", "sharedGlobals"], 5 | "production": [ 6 | "default", 7 | "!{projectRoot}/.eslintrc.json", 8 | "!{projectRoot}/eslint.config.js", 9 | "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", 10 | "!{projectRoot}/tsconfig.spec.json", 11 | "!{projectRoot}/jest.config.[jt]s", 12 | "!{projectRoot}/src/test-setup.[jt]s", 13 | "!{projectRoot}/test-setup.[jt]s" 14 | ], 15 | "sharedGlobals": [] 16 | }, 17 | "targetDefaults": { 18 | "@nx/esbuild:esbuild": { 19 | "cache": true, 20 | "dependsOn": ["^build"], 21 | "inputs": ["production", "^production"] 22 | } 23 | }, 24 | "plugins": [ 25 | { 26 | "plugin": "@nx/eslint/plugin", 27 | "options": { 28 | "targetName": "lint" 29 | } 30 | }, 31 | { 32 | "plugin": "@nx/jest/plugin", 33 | "options": { 34 | "targetName": "test" 35 | } 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tscc/source", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "dev": "nx run-many -t serve" 7 | }, 8 | "private": true, 9 | "devDependencies": { 10 | "@nx/esbuild": "18.0.8", 11 | "@nx/eslint": "18.0.8", 12 | "@nx/eslint-plugin": "18.0.8", 13 | "@nx/jest": "18.0.8", 14 | "@nx/js": "18.0.8", 15 | "@nx/node": "18.0.8", 16 | "@nx/workspace": "18.0.8", 17 | "@swc-node/register": "~1.8.0", 18 | "@swc/core": "~1.3.85", 19 | "@swc/helpers": "~0.5.2", 20 | "@types/express": "~4.17.13", 21 | "@types/jest": "^29.4.0", 22 | "@types/morgan": "^1.9.9", 23 | "@types/node": "~18.16.9", 24 | "@typescript-eslint/eslint-plugin": "^6.13.2", 25 | "@typescript-eslint/parser": "^6.13.2", 26 | "esbuild": "^0.19.2", 27 | "eslint": "~8.48.0", 28 | "eslint-config-prettier": "^9.0.0", 29 | "jest": "^29.4.1", 30 | "jest-environment-node": "^29.4.1", 31 | "nx": "18.0.8", 32 | "prettier": "^2.6.2", 33 | "ts-jest": "^29.1.0", 34 | "ts-node": "10.9.1", 35 | "typescript": "~5.3.2" 36 | }, 37 | "dependencies": { 38 | "@types/uuid": "^9.0.8", 39 | "auto-bind": "^v4.0.0", 40 | "axios": "^1.6.0", 41 | "express": "~4.18.1", 42 | "morgan": "^1.10.0", 43 | "tslib": "^2.3.0", 44 | "uuid": "^9.0.1", 45 | "zod": "^3.22.4", 46 | "zod-validation-error": "^3.0.3" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "sourceMap": true, 6 | "strict": true, 7 | "declaration": false, 8 | "moduleResolution": "node", 9 | "emitDecoratorMetadata": true, 10 | "experimentalDecorators": true, 11 | "importHelpers": true, 12 | "target": "es2015", 13 | "module": "esnext", 14 | "lib": ["es2020", "dom"], 15 | "skipLibCheck": true, 16 | "skipDefaultLibCheck": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@tscc/core": ["core/src/index.ts"] 20 | } 21 | }, 22 | "exclude": ["node_modules", "tmp"] 23 | } 24 | --------------------------------------------------------------------------------