https://www.typescriptlang.org/docs/
https://www.typescriptlang.org/docs/handbook/intro.html
https://www.typescriptlang.org/tsconfig/
https://nodejs.org/api/typescript.html
https://docs.npmjs.com/cli/v11/configuring-npm/package-json
https://typescript-eslint.io/getting-started/
https://vitest.dev/guide/

1. Important Points#

TypeScript = JavaScript + static type system:
    编译期发现错误
    IDE autocomplete / refactor 更可靠
    适合大型项目和多人协作
    runtime 仍然是 JavaScript

TypeScript 不会:
    自动校验 runtime 外部输入
    自动提升代码性能
    替代单元测试
    替代 API schema validation

核心原则:
    strict mode should be enabled
    type unknown external input first
    avoid any by default
    prefer type inference for local variables
    define explicit types for public API boundary
    config should be typed and validated
mental model:
    .ts source code
        -> type check by tsc
        -> run directly with tsx in dev
        -> build to JavaScript for production
        -> Node.js runs JavaScript

2. Environment Setup#

versions#

recommended:
    Node.js LTS
    npm from Node.js
    TypeScript latest stable

notes:
    Node.js has native TypeScript type stripping support in modern versions
    but project code still usually uses tsc for type checking
    tsx is convenient for local dev and scripts

create project#

mkdir order-api
cd order-api

npm init -y

npm install express dotenv zod pino
npm install -D typescript @types/node @types/express tsx vitest eslint @eslint/js typescript-eslint

initialize tsconfig#

npx tsc --init
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true,
    "sourceMap": true
  },
  "include": ["src/**/*.ts", "test/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}
important options:
    strict:
        enable strong type checking

    module / moduleResolution = NodeNext:
        align TypeScript with modern Node ESM behavior

    rootDir / outDir:
        keep source and build output separated

    noUncheckedIndexedAccess:
        array/map access returns possibly undefined

    exactOptionalPropertyTypes:
        optional property is more precise

3. Project Structure#

order-api
├── package.json
├── tsconfig.json
├── eslint.config.mjs
├── .env.example
├── .env.local
├── src
│   ├── main.ts
│   ├── app.ts
│   ├── config
│   │   └── config.ts
│   ├── logger
│   │   └── logger.ts
│   ├── errors
│   │   └── AppError.ts
│   ├── orders
│   │   ├── Order.ts
│   │   ├── OrderRepository.ts
│   │   ├── OrderService.ts
│   │   └── OrderRoutes.ts
│   └── shared
│       └── Result.ts
└── test
    └── OrderService.test.ts
structure rules:
    main.ts:
        process entrypoint

    app.ts:
        express app composition

    config/:
        environment config loading and validation

    logger/:
        logger singleton / factory

    errors/:
        typed application errors

    feature folder:
        domain type
        repository
        service
        routes/controller

    shared/:
        small reusable primitives only

4. Package / Dependency Management#

package.json#

{
  "name": "order-api",
  "version": "1.0.0",
  "type": "module",
  "private": true,
  "scripts": {
    "dev": "tsx watch src/main.ts",
    "build": "tsc",
    "start": "node dist/main.js",
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "test:watch": "vitest",
    "lint": "eslint .",
    "lint:fix": "eslint . --fix",
    "check": "npm run typecheck && npm run lint && npm test"
  },
  "dependencies": {
    "dotenv": "latest",
    "express": "latest",
    "pino": "latest",
    "zod": "latest"
  },
  "devDependencies": {
    "@types/express": "latest",
    "@types/node": "latest",
    "@eslint/js": "latest",
    "eslint": "latest",
    "tsx": "latest",
    "typescript": "latest",
    "typescript-eslint": "latest",
    "vitest": "latest"
  }
}
dependencies:
    runtime needs these packages
    production install needs them

devDependencies:
    build / test / lint / type package
    not needed at runtime after build

scripts:
    dev:
        local development

    build:
        compile TypeScript to dist

    start:
        run production JavaScript

    typecheck:
        check types without emitting files

import / export#

// createOrderId.ts
import { randomUUID } from "node:crypto";

// named export
export function createOrderId(): string {
  return `o-${randomUUID()}`;
}

// named import
import { createOrderId } from "./createOrderId.js";
NodeNext note:
    TypeScript source imports local files with .js extension
    source file is .ts, but emitted runtime file is .js

5. Syntax You Need Most#

primitive types#

const orderId: string = "o-1001";
const amount: number = 99.9;
const paid: boolean = false;
const tags: string[] = ["new", "mobile"];
rule:
    local variables often do not need explicit type
    public function args / return values should be explicit

object type#

type OrderStatus = "PENDING" | "PAID" | "CANCELLED";

type Order = {
  orderId: string;
  userId: string;
  amount: number;
  status: OrderStatus;
  createdAt: Date;
  paidAt?: Date;
};

interface vs type#

interface OrderRepository {
  findById(orderId: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

type CreateOrderInput = {
  userId: string;
  amount: number;
};
practical rule:
    interface:
        object contracts that may be implemented by classes

    type:
        unions, primitives, mapped types, DTOs

    both are fine for many object shapes
    be consistent inside the project

union#

type PaymentResult =
  | { ok: true; transactionId: string }
  | { ok: false; reason: "INSUFFICIENT_BALANCE" | "RISK_REJECTED" };

function handlePayment(result: PaymentResult): string {
  if (result.ok) {
    return result.transactionId;
  }

  return result.reason;
}

unknown#

function parseBody(body: unknown): CreateOrderInput {
  const schema = z.object({
    userId: z.string().min(1),
    amount: z.number().positive()
  });

  return schema.parse(body);
}
rule:
    external input should be unknown first
    validate it
    then convert it to typed object

async / await#

async function findOrder(orderId: string): Promise<Order | null> {
  return orderRepository.findById(orderId);
}

generic#

type ApiResponse<T> = {
  data: T;
  requestId: string;
};

const response: ApiResponse<Order> = {
  data: order,
  requestId: "req-1001"
};

utility types#

type OrderSummary = Pick<Order, "orderId" | "status" | "amount">;
type UpdateOrder = Partial<Pick<Order, "status" | "paidAt">>;
type OrderMap = Record<string, Order>;

6. Error Handling#

app error#

export class AppError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly statusCode: number
  ) {
    super(message);
    this.name = "AppError";
  }
}

service error#

export class OrderService {
  constructor(private readonly repository: OrderRepository) {}

  async markPaid(orderId: string): Promise<Order> {
    const order = await this.repository.findById(orderId);

    if (order === null) {
      throw new AppError("order not found", "ORDER_NOT_FOUND", 404);
    }

    if (order.status !== "PENDING") {
      throw new AppError("order status is not pending", "INVALID_ORDER_STATUS", 409);
    }

    const paidOrder: Order = {
      ...order,
      status: "PAID",
      paidAt: new Date()
    };

    await this.repository.save(paidOrder);
    return paidOrder;
  }
}

express error middleware#

import type { ErrorRequestHandler } from "express";

export const errorHandler: ErrorRequestHandler = (error, req, res, next) => {
  if (error instanceof AppError) {
    res.status(error.statusCode).json({
      error: {
        code: error.code,
        message: error.message
      }
    });
    return;
  }

  req.log?.error({ error }, "unexpected error");

  res.status(500).json({
    error: {
      code: "INTERNAL_ERROR",
      message: "internal server error"
    }
  });
};
rules:
    throw typed business errors
    never expose raw stack to client
    log unexpected errors
    validate external input before service layer

7. Testing#

vitest test#

import { describe, expect, it } from "vitest";

describe("OrderService", () => {
  it("marks pending order as paid", async () => {
    const repository = new InMemoryOrderRepository([
      {
        orderId: "o-1001",
        userId: "u-1001",
        amount: 99.9,
        status: "PENDING",
        createdAt: new Date("2026-05-29T10:00:00Z")
      }
    ]);

    const service = new OrderService(repository);
    const order = await service.markPaid("o-1001");

    expect(order.status).toBe("PAID");
    expect(order.paidAt).toBeInstanceOf(Date);
  });
});

what to test#

unit test:
    service business logic
    validation
    error mapping

integration test:
    database repository
    HTTP routes
    external dependency client

do not only test:
    happy path

8. Lint#

install#

npm install -D eslint @eslint/js typescript-eslint
packages:
    eslint:
        linter core

    @eslint/js:
        official JavaScript recommended rules

    typescript-eslint:
        TypeScript parser, plugin and shared configs

create config#

touch eslint.config.mjs
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";

export default tseslint.config(
  {
    ignores: [
      "dist/**",
      "node_modules/**",
      "coverage/**"
    ]
  },
  eslint.configs.recommended,
  ...tseslint.configs.recommended,
  {
    files: ["src/**/*.ts", "test/**/*.ts"],
    rules: {
      "@typescript-eslint/no-explicit-any": "warn",
      "@typescript-eslint/consistent-type-imports": "warn",
      "@typescript-eslint/no-unused-vars": [
        "warn",
        {
          "argsIgnorePattern": "^_",
          "varsIgnorePattern": "^_"
        }
      ]
    }
  }
);
lint config notes:
    ESLint v9 uses flat config by default
    eslint.config.mjs lives at project root
    ignore dist / coverage / node_modules
    recommended config catches common JavaScript issues
    typescript-eslint recommended config catches TypeScript-specific issues

typed linting#

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

export default tseslint.config(
  {
    ignores: ["dist/**", "node_modules/**", "coverage/**"]
  },
  eslint.configs.recommended,
  ...tseslint.configs.recommendedTypeChecked,
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.dirname
      }
    },
    rules: {
      "@typescript-eslint/no-floating-promises": "error",
      "@typescript-eslint/await-thenable": "error",
      "@typescript-eslint/no-misused-promises": "error"
    }
  }
);
typed linting:
    uses TypeScript type information
    catches deeper async / promise / unsafe usage issues
    slower than normal lint
    good for CI and serious backend projects

package scripts#

{
  "scripts": {
    "lint": "eslint .",
    "lint:fix": "eslint . --fix",
    "typecheck": "tsc --noEmit",
    "check": "npm run typecheck && npm run lint && npm test"
  }
}

run#

npm run lint
npm run lint:fix
npm run check
lint rules:
    lint:
        code quality and common mistakes

    typecheck:
        TypeScript type correctness

    test:
        behavior correctness

CI should run all three:
    npm run typecheck
    npm run lint
    npm test

9. Configuration By Environment#

files#

config organization:
    .env.example
        committed template

    .env.local
        local development
        not committed

    .env.test
        test defaults

    staging / prod:
        injected by deployment system
        Kubernetes Secret / ECS task env / CI secret manager
do not commit:
    .env.local
    .env.production
    real secret

.env.example#

NODE_ENV=local
PORT=3000
LOG_LEVEL=debug
DATABASE_URL=postgres://user:password@localhost:5432/order
PAYMENT_API_URL=http://localhost:8080

typed config#

import "dotenv/config";
import { z } from "zod";

const configSchema = z.object({
  NODE_ENV: z.enum(["local", "dev", "test", "staging", "prod"]).default("local"),
  PORT: z.coerce.number().int().positive().default(3000),
  LOG_LEVEL: z.enum(["trace", "debug", "info", "warn", "error"]).default("info"),
  DATABASE_URL: z.string().url(),
  PAYMENT_API_URL: z.string().url()
});

const parsed = configSchema.safeParse(process.env);

if (!parsed.success) {
  console.error(parsed.error.flatten().fieldErrors);
  process.exit(1);
}

export const config = {
  env: parsed.data.NODE_ENV,
  port: parsed.data.PORT,
  logLevel: parsed.data.LOG_LEVEL,
  databaseUrl: parsed.data.DATABASE_URL,
  paymentApiUrl: parsed.data.PAYMENT_API_URL
} as const;

config precedence#

recommended precedence:
    command line / runtime env
    secret manager injected env
    .env.<env>
    .env.local
    code default

production:
    prefer runtime env and secret manager
    fail fast when required config is missing

10. Logging#

import pino from "pino";
import { config } from "../config/config.js";

export const logger = pino({
  level: config.logLevel,
  base: {
    service: "order-api",
    env: config.env
  }
});
logging rules:
    structured JSON logs in production
    include service / env / request_id
    do not log password / token / secret
    log unexpected errors with stack
    use log levels consistently

11. Build / Run / Debug#

dev#

npm run dev

type check#

npm run typecheck

test#

npm test

build#

npm run build

production#

npm run start

debug#

node --inspect-brk dist/main.js
production rule:
    run compiled JavaScript from dist
    do not rely on tsx for production service startup
    typecheck in CI
    test in CI

12. Common Project Patterns#

repository#

export interface OrderRepository {
  findById(orderId: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

export class InMemoryOrderRepository implements OrderRepository {
  private readonly orders = new Map<string, Order>();

  constructor(initialOrders: Order[] = []) {
    for (const order of initialOrders) {
      this.orders.set(order.orderId, order);
    }
  }

  async findById(orderId: string): Promise<Order | null> {
    return this.orders.get(orderId) ?? null;
  }

  async save(order: Order): Promise<void> {
    this.orders.set(order.orderId, order);
  }
}

service#

import { randomUUID } from "node:crypto";

export class OrderService {
  constructor(private readonly repository: OrderRepository) {}

  async createOrder(input: CreateOrderInput): Promise<Order> {
    const order: Order = {
      orderId: randomUUID(),
      userId: input.userId,
      amount: input.amount,
      status: "PENDING",
      createdAt: new Date()
    };

    await this.repository.save(order);
    return order;
  }
}

route#

import { Router } from "express";
import { z } from "zod";

export function createOrderRouter(orderService: OrderService): Router {
  const router = Router();

  router.post("/orders", async (req, res, next) => {
    try {
      const input = z.object({
        userId: z.string().min(1),
        amount: z.number().positive()
      }).parse(req.body);

      const order = await orderService.createOrder(input);
      res.status(201).json(order);
    } catch (error) {
      next(error);
    }
  });

  return router;
}
pattern:
    route:
        parse HTTP input
        map HTTP response

    service:
        business logic

    repository:
        persistence

13. Hands-on#

src/orders/Order.ts#

export type OrderStatus = "PENDING" | "PAID" | "CANCELLED";

export type Order = {
  orderId: string;
  userId: string;
  amount: number;
  status: OrderStatus;
  createdAt: Date;
  paidAt?: Date;
};

export type CreateOrderInput = {
  userId: string;
  amount: number;
};

src/app.ts#

import { randomUUID } from "node:crypto";
import express from "express";
import { z } from "zod";

type Order = {
  orderId: string;
  userId: string;
  amount: number;
  status: "PENDING" | "PAID";
  createdAt: string;
};

const orders = new Map<string, Order>();

export function createApp() {
  const app = express();
  app.use(express.json());

  app.post("/orders", (req, res) => {
    const input = z.object({
      userId: z.string().min(1),
      amount: z.number().positive()
    }).parse(req.body);

    const order: Order = {
      orderId: randomUUID(),
      userId: input.userId,
      amount: input.amount,
      status: "PENDING",
      createdAt: new Date().toISOString()
    };

    orders.set(order.orderId, order);
    res.status(201).json(order);
  });

  app.get("/orders/:orderId", (req, res) => {
    const order = orders.get(req.params.orderId);

    if (order === undefined) {
      res.status(404).json({
        error: {
          code: "ORDER_NOT_FOUND",
          message: "order not found"
        }
      });
      return;
    }

    res.json(order);
  });

  return app;
}

src/main.ts#

import { createApp } from "./app.js";

const port = Number(process.env.PORT ?? 3000);
const app = createApp();

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

run#

npm run dev
curl -s -X POST http://localhost:3000/orders \
  -H 'content-type: application/json' \
  -d '{"userId":"u-1001","amount":99.9}'
you should understand:
    TypeScript types describe expected shape
    zod validates runtime input
    express handles HTTP
    tsx runs TypeScript in dev
    tsc builds JavaScript for production

14. Production Checklist#

project:
    strict mode enabled
    no any in business code unless justified
    tsconfig reviewed
    package scripts complete
    source and dist separated

config:
    env config validated at startup
    secrets not committed
    prod config injected by platform
    config precedence documented

code:
    public API boundary typed
    external input validated
    errors are typed
    async errors handled
    repository/service boundaries clear

test:
    unit tests for service logic
    integration tests for HTTP / database
    typecheck in CI
    lint in CI

runtime:
    structured logging
    request id / trace id
    health check endpoint
    graceful shutdown
    production runs dist/main.js