I built three basic CRUD apps using Express standard, Express typescript and Fastify.


Code is here: github.com/ezcg/typescript


This article will touch on some of the differences between javascript and Typescript as they show up in the express framework. Not a detailed look at Express nor Typescript, just things I found to be points of interest.


The package.json has the same structure for standard and typescript. The standard setup in package.json for a standard Express app:


"scripts": {
"startdev": "NODE_ENV=dev nodemon server.js"
}

Instead of using nodemon to run, restart and watch files, typescript uses tsx.


If your server lags or restarts infinitely, use the --ignore flag to skip those directories:

"startdev": "NODE_ENV=dev nodemon --watch . --ignore node_modules --ignore .git server.js"

Running this inside a Docker container or on a virtual machine requires a different system flag.


Because you are running inside a Docker container, nodemon cannot see file changes using standard operating system notifications. This happens because the file system events on your host machine do not reliably pass through the Docker volume mount to the Linux container.To fix this, you must force nodemon to use polling (legacy watch mode), which manually checks the files for changes every few milliseconds.Add the -L (or --legacy-watch) flag to your script:

"scripts": {
"startdev": "NODE_ENV=dev nodemon -L --watch . server.js"
}


tsx skips type checking during execution.

"scripts": {
"startdev": "NODE_ENV=dev tsx watch src/server.ts"
}


tsx strips out the types and runs the code instantly.If you make a TypeScript type error, your text editor will show it, but tsx will still run the app anyway. For production builds, you still rely on the standard TypeScript compiler (tsc) to verify your types.


Typescript makes use of more configuration options in the tsconfig.json file.


The tsconfig.json file is the master configuration file for TypeScript projects. In a TypeScript Express application, it acts as the instruction manual for the TypeScript compiler (tsc), telling it exactly how to translate your TypeScript code (.ts) into valid, production-ready JavaScript (.js).


Without this file, the compiler will not know how to handle modern Node.js imports, type checking, or where to save your compiled server files.


Typescript also uses the file eslint.config.js The eslint.config.js file is the central configuration file for ESLint, which acts as your project's code quality inspector.


While tsconfig.json makes sure your TypeScript code compiles successfully, eslint.config.js analyzes your code syntax to catch programmatic bugs, enforce style consistency, and prevent bad coding habits before your Express app runs.


For example, to use console.log in a typescript app, you have to add the comment on the line before console.log:

// eslint-disable-next-line no-console
console.log("at line 10");

or add a rule to the eslint.config.js file. This is because typescript treats any console.log as a linting error to prevent debug logs from reaching production. Running tsc filename.ts only compiles the code; it won't show your logs. You must execute the resulting .js file with Node.js or use a tool like ts-node to see the output.


In eslint.config.js, there is the rule

rules: {
"no-console": "off"
}

This enables using console.log() without triggering a linting error.


The app is initialized in express_typescript/src/app.ts and express_standard/app.js with the line of code:

const app = express();


In express_standard, the app.use method below defines a custom global error-handling middleware function. The app.use method in the Express standard file app.js:

app.use((err, req, res, next) => {
console.error(err);
res.status(err.statusCode || 500).send(err.message || 'Internal Server Error');
});


Core Functionality

  1. Error Detection: Express recognizes error-handling middleware strictly by its four-argument signature.
  2. Catch-All Behavior: It captures any error thrown in routes or explicitly passed forward via next(err).
  3. Placement: It must be placed at the very bottom of your file, after all other routes and middleware.

Parameter Breakdown

  1. err: The error object containing the message and stack trace.
  2. req: The incoming HTTP request object.
  3. res: The HTTP response object used to send data back to the client.
  4. next: The function required to pass execution to the next error middleware, if one exists.


In express_typescript, the app.use method in the Express typescript file app.ts:

app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
const error = err as { message?: string; statusCode?: number };
res.status(error.statusCode ?? 500).send(error.message ?? 'Internal Server Error');
});


Key Characteristics

  1. Four Parameters: Express identifies error-handling middleware exclusively by its four-argument signature (err, req, res, next).
  2. Catch-All Mechanism: It intercepts errors thrown or passed via next(err) from any preceding routes or middleware.
  3. Terminal Execution: It typically sits at the very bottom of the Express middleware stack, just before starting the server.

Parameter Breakdown

  1. err: unknown: The error object caught by Express, containing the error message and stack trace.
  2. _req: express.Request: The incoming HTTP request object (prefixed with an underscore because it is unused).
  3. res: express.Response: The HTTP response object used to send status codes and data back to the client.
  4. _next: express.NextFunction: The function to pass execution to the next middleware (unused here as error handlers usually terminate the request-response cycle).


The err Parameter


The err parameter is of an unknown type, so it is declared as 'unknown'. There are rules and restrictions on variables set as an unknown type.


  1. Assignability: You can assign anything to an unknown variable, but you cannot assign an unknown variable to anything else except any or itself without a type check.
  2. Restricted Operations: Unlike any, which lets you call any method (e.g., .split()) regardless of the actual value, unknown triggers a compiler error if you try to use it before type narrowing.
  3. Purpose: It signals that a value's type is truly not known yet (like data from an API or a caught error), forcing you to handle the uncertainty safely.


The setting of const error


This declaration of the constant error

const error = err as { message?: string; statusCode?: number };

is an assertion used to tell the compiler to treat a generic, unknown error variable as a specific object structure.


The 'as' in the phrase 'err as' is a TypeScript keyword for type assertion.


Regarding 'message?:' The ? means the property is optional. The error might have a message, but it's not guaranteed.


Same for 'statusCode?:' The ? means the status code is also optional.


The response


res.status(error.statusCode ?? 500).send(error.message ?? 'Internal Server Error');


  1. res.status(...): Sets the HTTP response status code.
  2. The two question marks ?? is "Nullish Coalescing". It triggers an evaluation of the right side only if the left side is null or undefined. So for error.statusCode the right side value 500 is a default value if there is no value set in statusCode
  3. .send(...): Finalizes the request-response cycle and sends the payload to the client.

Why ?? is Used Instead of ||


  1. The ?? operator prevents unexpected behavior with falsy values like empty strings "" or the number 0.
  2. Using error.message || 'Backup' would overwrite an explicit empty message ("") with 'Backup'.
  3. Using error.message ?? 'Backup' preserves the empty string "" because it is not null or undefined.


In the file express_typescript file src/routes/links.routes.ts the code


import { Router } from 'express';


works fine, but in the express_standard file routes/links.routes.js the code editor gives a warning, "Element is not exported".


That warning comes from the Express type definitions, not from the route logic.


In express_standard/routes/links.routes.js, Router is not exposed as a named export in the way the editor expects, so this import trips the warning.


This code uses the standard Express pattern and the warning no longer occurs:


import express from 'express';
const router = express.Router();


In express_standard links.controller.js, there is the customary normalizing of submitted data:

function normalizeLinkPayload(body) {
const linkName = typeof body.linkName === 'string' ? body.linkName.trim() : null;
const url = typeof body.url === 'string' ? body.url.trim() : null;

return {
linkName: linkName || null,
url: url || null
};
}


In express_typescript, it is basically the same.

function normalizeLinkPayload(body: Request['body']): LinkInput {
const linkName = typeof body.linkName === 'string' ? body.linkName.trim() : '';
const url = typeof body.url === 'string' ? body.url.trim() : '';

return {
linkName: linkName.length > 0 ? linkName : null,
url: url.length > 0 ? url : null
};
}

The function declares LinkInput as the return type: function normalizeLinkPayloadBody(...): LinkInput {...}


LinkInput is defined and exported in services/links.service.ts

export interface LinkInput {
linkName: string | null;
url: string | null;
}

and imported in links.controller.ts


In express_typescript links.controller.ts, there is the method that gets called when data is posted to create a row in the database; createLinkHandler(). In the method, the posted data gets normalized and passed to a method createLink() that inserts a row into the database.

await createLink(normalizeLinkPayload(req.body));


In the services/links.service.ts, there is the createLink() method.

export async function createLink(input: LinkInput): Promise {
return Link.create(input);
}

createLink expects a parameter of type LinkInput passed to it. LinkInput is defined in services/links.ts and shown above towards the beginning of this article. Code that calls the createLink method expect a LinkInstance type to be returned from it.


LinkInstance is declared in the services/links.service.ts

export type LinkInstance = InstanceType;


The Link that LinkInstance is a typeof is initialized in models/index.ts and the definition of Link happens in models/link.model.ts.


In models/link.model.ts, Link is defined

export class Link extends Model, InferCreationAttributes> {
declare id: CreationOptional;
declare linkName: string | null;
declare url: string | null;
}

It configures a strongly typed Link model class, mapping database table rows to TypeScript objects.


How the Syntax Works

  1. Model: The base class provided by Sequelize.
  2. InferAttributes: Automatically extracts the read-based properties of your model class (every attribute returned when you fetch data).
  3. InferCreationAttributes: Extracts the write-based properties needed when you insert a new row (e.g., auto-incremented primary keys or createdAt/updatedAt timestamps can be optional).