open atlas
↑ К треку
Система типов TypeScript вглубь TS · 06 · 02

Типизация this

Как TypeScript типизирует скрытый первый параметр через `this: T` (стирается при эмите), почему метод теряет свой `this` при передаче как колбэк, и как ThisType, полиморфный `this` и this-гарды моделируют fluent- и stateful-API.

TS Middle ◷ 14 min
Уровень
ОсновыJuniorMiddleSenior

Классовый компонент работает идеально, пока кто-то не пишет button.addEventListener("click", this.handleClick). В рантайме this внутри handleClick теперь — это сам <button>, а не компонент, и чтения this.state оказываются undefined. В чистом JavaScript баг невидим, пока не упадёт. TypeScript может поймать его на этапе компиляции — но только если вы понимаете, что у каждого метода есть скрытый первый параметр с именем this, и что отделение метода срывает эту привязку.

Фейковый первый параметр

Зачем TypeScript вообще нужен особый механизм для this? Потому что this — единственное значение в JavaScript, которое не определяется местом определения функции: оно задаётся заново в каждой точке вызова, и компилятор не может вывести его из исходного положения.

Функции в JavaScript получают неявного получателя: значение this, определяемое тем, как функцию вызвали, а не где её определили. TypeScript позволяет типизировать этого получателя специальным первым параметром с именем this. Он выглядит как параметр, но им не является — он стирается при эмите и никогда не появляется в списке аргументов вызова.

interface Card { suit: string; value: number }

function describe(this: Card): string {
  return `${this.value} of ${this.suit}`;
}

const card: Card = { suit: "hearts", value: 10 };
describe.call(card); // ok — `this` подаётся через .call
describe();
// Error: The 'this' context of type 'void' is not assignable
//        to method's 'this' of type 'Card'.

Сэмиттированный JavaScript — это просто function describe() { return this.value + ...; }: аннотация this: Card исчезла. Её единственная задача была — проверить получателя в каждой точке вызова. При --noImplicitThis (входит в strict) this, использованный внутри функции без аннотации и без выводимого контейнера, помечается как неявный any, заставляя вас его объявить.

Методы несут выведенный this и теряют его при отделении

Внутри метода объекта или метода класса TypeScript выводит this как содержащий объект/экземпляр. Проблема возникает, когда вы отделяете метод — присваиваете его переменной или передаёте как колбэк, — потому что привязку задаёт вызывающий, а не определение.

class Counter {
  count = 0;
  inc(this: Counter) {     // явный this ради безопасности
    this.count++;
  }
}

const c = new Counter();
c.inc();          // ok — получатель `c`
const f = c.inc;  // отделён
f();
// Error: The 'this' context of type 'void' is not assignable
//        to method's 'this' of type 'Counter'.

Это проблема потерянного this. При проверках strictBindCallApply и метода-this TypeScript помечает несвязанный вызов. Рантайм-фиксы привычные — c.inc.bind(c) или стрелочное поле-функция inc = () => { this.count++ }, чей this лексически является экземпляром, — но именно система типов выводит баг на поверхность до того, как он уедет в прод.

Почему это работает

Почему стрелочная функция чинит это, а метод — нет? У метода this динамичен — разрешается из вызова. У стрелочной функции нет своего this; она замыкается над this объемлющей области в момент определения. Поэтому поле класса onClick = () => … лексически захватывает экземпляр, и вызывающий не может его переназначить. Цена — одно замыкание на экземпляр вместо одного общего метода прототипа — обычно пренебрежимо, изредка значимо для объектов, создаваемых в горячих циклах.

This-гарды типов и полиморфный this

Две this-типизированные возможности питают реальные API. Во-первых, метод может вернуть this is Derivedthis-гард типа, который сужает самого получателя в вызывающей области:

class FileNode {
  isDir(): this is DirNode {   // сужает `this`, а не параметр
    return "children" in this;
  }
}
class DirNode extends FileNode {
  children: FileNode[] = [];
}

declare const node: FileNode;
if (node.isDir()) {
  node.children;
  //   ^? FileNode[]   — node сужен до DirNode внутри ветки
}

Во-вторых, полиморфный this делает тип возврата метода «каким бы ни был текущий класс», что держит fluent-билдеры (API с цепочкой вызовов вида .where(...).byEmail(...)) корректно типизированными при наследовании:

class Query {
  where(cond: string): this {   // возвращает конкретный подкласс, а не Query
    return this;
  }
}
class UserQuery extends Query {
  byEmail(e: string): this { return this; }
}

new UserQuery().where("active").byEmail("a@b.com");
//                                ^^^^^^^ ok — where() вернул UserQuery, а не Query

Если бы where вернул Query вместо this, цепочка сломалась бы на .byEmail, потому что у Query нет такого метода. Полиморфный this — это механизм на уровне типов за цепочечными билдерами. ThisType<T> — родственный маркер контекстной типизации, который стандартная библиотека использует, чтобы типизировать this внутри объектных литералов с наборами методов (объект options во Vue 2, миксины, fluent-конфиг), так что методы внутри литерала видят весь собранный объект как свой this.

Викторина

Дано `const f = obj.method; f();`, где `method` типизирован с `this: Obj`. Почему TypeScript помечает вызов `f()`?

Закончи аналогию

`this` метода — как ключ-карта отеля, работающая, лишь пока вы заселены в конкретный номер. Пронеси голую карту в другое здание, и система по аналогии сочтёт её принадлежащей ___ — поэтому отделённый метод теряет своего исходного получателя.

Вспомните перед уходом
  1. 01
    Объясните проблему потерянного `this` от начала до конца: как TypeScript типизирует `this`, почему отделение метода её ломает и два стандартных фикса.
  2. 02
    Сравните this-гард типа, полиморфный `this` и ThisType<T> — что моделирует каждый?
Итог

TypeScript типизирует получателя на момент вызова фейковым первым параметром this: T, который стирается при эмите и нужен лишь для проверки получателя в каждой точке вызова; --noImplicitThisstrict) заставляет объявить его, когда вывести нельзя. Методы выводят this как свой контейнер, но привязка динамична, поэтому отделение метода теряет this — баг потерянного this, который TypeScript ловит проверками this-параметра и strictBindCallApply, а чинят его .bind или стрелочное поле класса. Для API this is Derived сужает получателя, полиморфный this типизирует fluent-билдеры при наследовании, а ThisType<T> типизирует this внутри объектных литералов с наборами методов. Дальше мы переходим от типизации получателя к изменению потока управления: функции-утверждения, сигнатуры asserts x is T, сужающие остаток области через выброс исключения. Теперь, когда вы видите краш с this.state is undefined внутри обработчика событий, вы знаете ровно, что произошло — и знаете две строки, которые нужны, чтобы починить это до того, как баг уедет в прод.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки
углубляется в

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.