← TypeScript/TypeScript с Express#248 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: TypeScript setТермин: TypeScriptМаршрут: старт с нуля
← НазадДалее →

TypeScript с Express

Зачем это нужно в backend на Node.js

Express без типов удобен на старте, но в API-проектах быстро появляются риски: в req.body прилетает неожиданный формат, параметры маршрута интерпретируются неверно, middleware «ломает» объект запроса. TypeScript помогает сделать API предсказуемым и безопасным.

Ключевой принцип: типизируй входные данные на границах приложения — params, query, body и расширения Request в middleware. Тогда ошибки ловятся до запуска сервера.

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

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

Практические рекомендации

  • Держи типы запросов рядом с контроллерами, чтобы контракт был виден в одном месте.
  • Не доверяй только TypeScript: типы не валидируют runtime-данные, поэтому добавляй валидацию входа (zod/yup/joi).
  • Для больших проектов выноси доменные типы в отдельный слой и не смешивай их с transport-типами HTTP.
  • Примеры

    Полный мини-фреймворк в стиле 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)

    TypeScript с Express

    Зачем это нужно в backend на Node.js

    Express без типов удобен на старте, но в API-проектах быстро появляются риски: в req.body прилетает неожиданный формат, параметры маршрута интерпретируются неверно, middleware «ломает» объект запроса. TypeScript помогает сделать API предсказуемым и безопасным.

    Ключевой принцип: типизируй входные данные на границах приложения — params, query, body и расширения Request в middleware. Тогда ошибки ловятся до запуска сервера.

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

    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

    Практические рекомендации

  • Держи типы запросов рядом с контроллерами, чтобы контракт был виден в одном месте.
  • Не доверяй только TypeScript: типы не валидируют runtime-данные, поэтому добавляй валидацию входа (zod/yup/joi).
  • Для больших проектов выноси доменные типы в отдельный слой и не смешивай их с transport-типами HTTP.
  • Примеры

    Полный мини-фреймворк в стиле 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)

    Задание

    Реализуй `createRouter()` — объект с методами `get`, `post`, `put`, `delete` для регистрации маршрутов и `handle(method, path, body)` для их обработки. Поддержи параметры маршрута (`:id`): они должны быть доступны в `req.params`. Каждый обработчик получает `req = { params, body, query }` и `res = { json(data), status(code) }`. `handle` возвращает `{ status, body }`.

    Подсказка

    matchPath: pathParts[i] — значение соответствующей части пути. addRoute: routes.push({ method, path, handler }). В handle: перебирай routes, вызывай matchPath, если params !== null — создай req/res и вызови handler.

    Загружаем среду выполнения...
    Загружаем AI-помощника...