← Курс/TypeScript с Express#192 из 257+25 XP

TypeScript с Express

Установка и настройка

npm install express
npm install -D @types/express typescript @types/node ts-node
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "./dist"
  }
}

Request и Response типы

import express, { Request, Response, NextFunction } from 'express'

const app = express()

app.get('/users', (req: Request, res: Response) => {
  res.json({ users: [] })
})

Типизация параметров маршрута

Express Router предоставляет дженерики для параметров:

// Request<Params, ResBody, ReqBody, Query>

interface UserParams {
  id: string  // всегда строка в Express params
}

app.get('/users/:id', (
  req: Request<UserParams>,
  res: Response
) => {
  const userId = parseInt(req.params.id)  // приводим к числу
  res.json({ id: userId })
})

Типизация req.body

interface CreateUserBody {
  name: string
  email: string
  role?: 'admin' | 'user'
}

app.post('/users', (
  req: Request<{}, {}, CreateUserBody>,
  res: Response
) => {
  const { name, email, role = 'user' } = req.body
  // name: string, email: string — TypeScript доволен
  res.status(201).json({ id: 1, name, email, role })
})

Типизация query-параметров

interface UsersQuery {
  page?: string
  limit?: string
  search?: string
  role?: 'admin' | 'user'
}

app.get('/users', (
  req: Request<{}, {}, {}, UsersQuery>,
  res: Response
) => {
  const page = parseInt(req.query.page || '1')
  const limit = parseInt(req.query.limit || '10')
  const search = req.query.search || ''
  res.json({ page, limit, search })
})

Типизация middleware

// Middleware: Request -> Response -> NextFunction
function logger(req: Request, res: Response, next: NextFunction): void {
  console.log(`${req.method} ${req.path}`)
  next()
}

// Middleware с ошибкой (4 параметра):
function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
): void {
  console.error(err.stack)
  res.status(500).json({ error: err.message })
}

app.use(logger)
app.use(errorHandler)

Расширение Request через Module Augmentation

// types/express.d.ts
declare module 'express-serve-static-core' {
  interface Request {
    user?: {
      id: number
      email: string
      role: 'admin' | 'user'
    }
    startTime?: number
  }
}
// middleware/auth.ts
function authMiddleware(req: Request, res: Response, next: NextFunction) {
  const token = req.headers.authorization?.split(' ')[1]
  if (!token) return res.status(401).json({ error: 'Unauthorized' })

  req.user = verifyToken(token)  // TypeScript знает о req.user
  next()
}

Типизированный Router

// routes/users.ts
import { Router } from 'express'
const router = Router()

router.get('/', getUsers)
router.get('/:id', getUserById)
router.post('/', createUser)
router.put('/:id', updateUser)
router.delete('/:id', deleteUser)

export default router

Примеры

Полный мини-фреймворк в стиле Express с типизированными роутами, middleware, body parsing и error handling

// Реализуем Express-подобный фреймворк в чистом JS.
// В TypeScript каждый роут имел бы строгие типы Request/Response.

// --- Express-подобный Application ---
class Application {
  constructor() {
    this._middleware = []
    this._routes = []
    this._errorHandlers = []
  }

  use(pathOrMiddleware, middleware) {
    if (typeof pathOrMiddleware === 'function') {
      // app.use(middleware)
      this._middleware.push({ path: null, handler: pathOrMiddleware })
    } else {
      // app.use('/path', middleware)
      this._middleware.push({ path: pathOrMiddleware, handler: middleware })
    }
  }

  get(path, ...handlers) { this._addRoute('GET', path, handlers) }
  post(path, ...handlers) { this._addRoute('POST', path, handlers) }
  put(path, ...handlers) { this._addRoute('PUT', path, handlers) }
  delete(path, ...handlers) { this._addRoute('DELETE', path, handlers) }

  _addRoute(method, path, handlers) {
    this._routes.push({ method, path, handlers })
  }

  _matchRoute(method, url) {
    for (const route of this._routes) {
      if (route.method !== method) continue
      const params = this._extractParams(route.path, url)
      if (params !== null) return { ...route, params }
    }
    return null
  }

  _extractParams(routePath, url) {
    // /users/:id -> { id: '42' }
    const routeParts = routePath.split('/')
    const urlParts = url.split('?')[0].split('/')

    if (routeParts.length !== urlParts.length) return null

    const params = {}
    for (let i = 0; i < routeParts.length; i++) {
      if (routeParts[i].startsWith(':')) {
        params[routeParts[i].slice(1)] = urlParts[i]
      } else if (routeParts[i] !== urlParts[i]) {
        return null
      }
    }
    return params
  }

  _parseQuery(url) {
    const queryStr = url.split('?')[1] || ''
    const query = {}
    queryStr.split('&').filter(Boolean).forEach(part => {
      const [k, v] = part.split('=')
      query[decodeURIComponent(k)] = decodeURIComponent(v || '')
    })
    return query
  }

  handle(method, url, body = null) {
    const req = {
      method,
      url,
      path: url.split('?')[0],
      params: {},
      query: this._parseQuery(url),
      body: body || {},
      headers: { authorization: null },
      user: null,  // расширяем через module augmentation в TS
    }

    const res = {
      statusCode: 200,
      _data: null,
      _headers: {},
      status(code) { this.statusCode = code; return this },
      json(data) { this._data = data; return this },
      send(text) { this._data = text; return this },
      set(header, value) { this._headers[header] = value; return this },
    }

    const route = this._matchRoute(method, url)
    if (route) req.params = route.params

    const handlers = []

    // Добавляем middleware
    for (const mw of this._middleware) {
      if (!mw.path || req.path.startsWith(mw.path)) {
        handlers.push(mw.handler)
      }
    }

    // Добавляем обработчики маршрута
    if (route) {
      handlers.push(...route.handlers)
    } else {
      handlers.push((req, res) => res.status(404).json({ error: 'Not Found' }))
    }

    // Запускаем цепочку
    let idx = 0
    const next = (err) => {
      if (err) {
        this._errorHandlers.forEach(h => h(err, req, res, () => {}))
        return
      }
      if (idx < handlers.length) {
        try {
          handlers[idx++](req, res, next)
        } catch (e) {
          next(e)
        }
      }
    }
    next()

    return { status: res.statusCode, body: res._data }
  }
}

// --- Типизированные handlers (как в TS + Express) ---

// TS: interface User { id: number; name: string; role: 'admin' | 'user' }
const users = [
  { id: 1, name: 'Алексей', role: 'admin', email: 'alex@example.com' },
  { id: 2, name: 'Мария', role: 'user', email: 'maria@example.com' },
]

const app = new Application()

// Logger middleware
// TS: (req: Request, res: Response, next: NextFunction) => void
app.use((req, res, next) => {
  console.log(`[LOG] ${req.method} ${req.url}`)
  next()
})

// GET /users?role=admin&page=1
// TS: Request<{}, {}, {}, { role?: 'admin'|'user'; page?: string }>
app.get('/users', (req, res) => {
  let result = [...users]
  if (req.query.role) {
    result = result.filter(u => u.role === req.query.role)
  }
  const page = parseInt(req.query.page || '1')
  const limit = parseInt(req.query.limit || '10')
  res.json({ users: result, page, total: result.length })
})

// GET /users/:id
// TS: Request<{ id: string }>
app.get('/users/:id', (req, res) => {
  const user = users.find(u => u.id === parseInt(req.params.id))
  if (!user) return res.status(404).json({ error: 'User not found' })
  res.json(user)
})

// POST /users
// TS: Request<{}, {}, { name: string; email: string; role?: 'admin'|'user' }>
app.post('/users', (req, res) => {
  const { name, email, role = 'user' } = req.body
  if (!name || !email) {
    return res.status(400).json({ error: 'name and email required' })
  }
  const newUser = { id: users.length + 1, name, email, role }
  users.push(newUser)
  res.status(201).json(newUser)
})

// --- Демонстрация ---
console.log('=== GET /users ===')
let r = app.handle('GET', '/users')
console.log('status:', r.status, 'count:', r.body.users.length)

console.log('\n=== GET /users?role=admin ===')
r = app.handle('GET', '/users?role=admin')
console.log('status:', r.status, 'users:', r.body.users.map(u => u.name))

console.log('\n=== GET /users/2 ===')
r = app.handle('GET', '/users/2')
console.log('status:', r.status, 'user:', r.body.name)

console.log('\n=== GET /users/99 (not found) ===')
r = app.handle('GET', '/users/99')
console.log('status:', r.status, 'error:', r.body.error)

console.log('\n=== POST /users ===')
r = app.handle('POST', '/users', { name: 'Иван', email: 'ivan@example.com' })
console.log('status:', r.status, 'created:', r.body)

console.log('\n=== POST /users (validation error) ===')
r = app.handle('POST', '/users', { name: 'Нет email' })
console.log('status:', r.status, 'error:', r.body.error)