Links#
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#
{
"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#
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#
type check#
test#
build#
production#
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#
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