← Курс/this в TypeScript: типизация контекста#168 из 257+25 XP

this в TypeScript: типизация контекста

Параметр this в функциях

TypeScript позволяет явно указать тип this в функции. Это фиктивный параметр — он не попадает в скомпилированный JS, но даёт TypeScript информацию о контексте вызова.

interface User {
  name: string
  greet(this: User): string
}

function greet(this: User): string {
  return `Привет, я ${this.name}`
}

const user: User = { name: 'Алексей', greet }
user.greet()   // OK — this будет User

// Ошибка TS: Cannot assign 'greet' to standalone function
// without ensuring 'this' is User
const fn = user.greet
// fn()  // Ошибка TS: void context is not compatible with User

this возвращаемый тип и Method Chaining

Возвращаемый тип this позволяет строить цепочки методов с правильной типизацией даже в подклассах:

class Builder {
  protected config: Record<string, any> = {}

  set(key: string, value: any): this {   // возвращает this — важно!
    this.config[key] = value
    return this
  }

  build(): Record<string, any> {
    return { ...this.config }
  }
}

class QueryBuilder extends Builder {
  table(name: string): this {
    return this.set('table', name)
  }

  limit(n: number): this {
    return this.set('limit', n)
  }
}

const query = new QueryBuilder()
  .table('users')      // возвращает QueryBuilder, не Builder!
  .set('order', 'name')
  .limit(10)
  .build()

Arrow functions и this

Стрелочные функции захватывают this из окружающего контекста:

class Timer {
  private seconds = 0

  // Обычный метод — this зависит от вызова
  startRegular() {
    setInterval(function() {
      // this.seconds++  // Ошибка! this — не Timer в callback
    }, 1000)
  }

  // Стрелочная функция — this всегда Timer
  startArrow() {
    setInterval(() => {
      this.seconds++   // OK! this захвачен из startArrow
      console.log(this.seconds)
    }, 1000)
  }

  // Метод-стрелка в поле класса — всегда сохраняет this
  handleClick = () => {
    console.log(this.seconds)   // OK при любом вызове
  }
}

ThisType утилита

ThisType<T> позволяет типизировать this в объектных методах без классов:

interface AppState {
  count: number
  text: string
}

interface AppMethods {
  increment(): void
  setText(text: string): void
  reset(): void
}

// ThisType<AppState & AppMethods> — говорит TS что this внутри — это AppState + AppMethods
const methods: AppMethods & ThisType<AppState & AppMethods> = {
  increment() { this.count++ },          // this.count типизирован
  setText(text) { this.text = text },    // this.text типизирован
  reset() { this.count = 0; this.text = '' },
}

function createApp(state: AppState, methods: AppMethods & ThisType<AppState & AppMethods>) {
  return Object.assign(state, methods)
}

const app = createApp({ count: 0, text: '' }, methods)
app.increment()
app.setText('Привет')
console.log(app.count, app.text)  // 1, 'Привет'

Типичные ловушки с this

class EventHandler {
  message = 'Привет!'

  // Обычный метод — теряет this при передаче как колбэк
  handleClick() {
    console.log(this.message)  // this может быть undefined!
  }

  // Метод-стрелка — this всегда EventHandler
  handleClickBound = () => {
    console.log(this.message)  // OK
  }
}

const handler = new EventHandler()
const btn = { onclick: null }

btn.onclick = handler.handleClick      // опасно — теряем this
btn.onclick = handler.handleClickBound // безопасно
btn.onclick = handler.handleClick.bind(handler)  // тоже безопасно

Примеры

Method chaining с правильным this: построитель SQL-запросов и конфигуратор

// TypeScript: методы возвращают this для поддержки цепочек даже в подклассах
// В JS this работает так же — нужно только правильно возвращать this

class QueryBuilder {
  #parts = {
    table:      null,
    conditions: [],
    columns:    ['*'],
    orderBy:    null,
    limitVal:   null,
    offsetVal:  null,
  }

  from(table) {
    this.#parts.table = table
    return this  // возвращаем this для chaining
  }

  select(...columns) {
    this.#parts.columns = columns
    return this
  }

  where(condition) {
    this.#parts.conditions.push(condition)
    return this
  }

  orderBy(column, direction = 'ASC') {
    this.#parts.orderBy = `${column} ${direction}`
    return this
  }

  limit(n) {
    this.#parts.limitVal = n
    return this
  }

  offset(n) {
    this.#parts.offsetVal = n
    return this
  }

  build() {
    if (!this.#parts.table) throw new Error('Таблица не указана')

    let sql = `SELECT ${this.#parts.columns.join(', ')} FROM ${this.#parts.table}`

    if (this.#parts.conditions.length > 0) {
      sql += ` WHERE ${this.#parts.conditions.join(' AND ')}`
    }
    if (this.#parts.orderBy) sql += ` ORDER BY ${this.#parts.orderBy}`
    if (this.#parts.limitVal != null)  sql += ` LIMIT ${this.#parts.limitVal}`
    if (this.#parts.offsetVal != null) sql += ` OFFSET ${this.#parts.offsetVal}`

    return sql
  }
}

// Расширяем — this в методах родителя будет PaginatedQueryBuilder!
class PaginatedQueryBuilder extends QueryBuilder {
  page(pageNum, pageSize = 20) {
    this.limit(pageSize)
    this.offset((pageNum - 1) * pageSize)
    return this  // this — PaginatedQueryBuilder
  }
}

// Демонстрация потери this и решения
class Timer {
  #seconds = 0
  #intervalId = null

  // Обычный метод — this зависит от контекста вызова
  tick() {
    this.#seconds++
    console.log(`Тик: ${this.#seconds}`)
  }

  // Метод-стрелка (поле класса) — this всегда привязан
  tickBound = () => {
    this.#seconds++
    console.log(`Тик (bound): ${this.#seconds}`)
  }

  start() {
    // tick() потеряет this без bind:
    // this.#intervalId = setInterval(this.tick, 1000)  // НЕПРАВИЛЬНО

    // Правильный вариант 1: .bind(this)
    // this.#intervalId = setInterval(this.tick.bind(this), 100)

    // Правильный вариант 2: стрелочная функция
    // this.#intervalId = setInterval(() => this.tick(), 100)

    // Правильный вариант 3: метод-стрелка (tickBound)
    this.#intervalId = setInterval(this.tickBound, 100)
    return this
  }

  stop() {
    clearInterval(this.#intervalId)
    console.log(`Остановлен на: ${this.#seconds}`)
    return this
  }

  get seconds() { return this.#seconds }
}

// --- Демонстрация QueryBuilder ---
console.log('=== QueryBuilder (method chaining) ===')
const q1 = new QueryBuilder()
  .from('users')
  .select('id', 'name', 'email')
  .where('active = true')
  .where('age > 18')
  .orderBy('name')
  .limit(10)
  .build()

console.log(q1)

// PaginatedQueryBuilder — наследует все методы, this правильный
const q2 = new PaginatedQueryBuilder()
  .from('products')
  .where('price > 1000')
  .orderBy('price', 'DESC')
  .page(2, 5)  // страница 2, 5 элементов
  .build()

console.log(q2)

// Потеря this
console.log('\n=== Потеря this ===')
const timer = new Timer()
const extracted = timer.tick  // обычный метод извлечён
try {
  extracted()  // this = undefined
} catch (e) {
  console.log('Ошибка (потеря this):', e.message || e.constructor.name)
}

// Правильно: bind
const bound = timer.tick.bind(timer)
bound()  // this = timer, работает

// Метод-стрелка не теряет this
const arrow = timer.tickBound
arrow()  // this = timer, всегда работает