Раздел 46 · Effect: Тихая Академия

Чтение сигнатур

middle~30 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Сундук остался у двери Библиотеки: внутрь его не пускают. А тебя пускают, и вот зачем. В прошлом уроке ты писал дженерики сам: канал Channel<M>, маршрут, который помнит имена башен. Сегодня ты читаешь чужие. Чужих сигнатур скорописец читает больше, чем пишет своих: стандартная библиотека, фреймворк, драйвер базы, а впереди Effect, где сигнатура функции бывает длиннее её документации.

Обычно ты наводишь курсор и смотришь, что написал редактор. Это работает, пока тип короткий. Когда он длиннее строки, редактор обрезает его многоточием, а на вопрос «почему такой» не отвечает никогда. Библиотекарь учит другому: открыть объявление и прочитать его так, чтобы ответ получился раньше, чем ты навёл курсор.

Как читать карточку вслух

Вот карточка, которую раскрыл Библиотекарь. Это объявление из lib.es5.d.ts, файла, где TypeScript описывает встроенные объекты JavaScript:

interface Array<T> {
  map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];
}

Это сигнатура: имя, параметры типа, параметры, тип результата. Читай её в четыре шага.

  1. Что уже известно. T принадлежит массиву, а не методу. Он решён в момент, когда массив создан: у [1, 2, 3] тип number[], значит, T это number.
  2. Что новое. <U> после имени метода это параметр типа, который map узнает при вызове.
  3. Откуда его взять. Найди, где U стоит в параметрах. Здесь одно место: то, что возвращает колбэк. Такое место называют местом вывода: TypeScript посмотрит, что вернула твоя стрелка, и подставит это в U.
  4. Что выйдет. Справа от двоеточия U[].

Теперь вслух: «map для любого U берёт функцию, которая из T делает U, и отдаёт массив U». Подставь строку Библиотекаря. Стрелка (n) => n > 1 получает n: number, потому что value: T, а возвращает сравнение, то есть boolean. U это boolean, результат boolean[].

const flags = [1, 2, 3].map((n) => n > 1);
// boolean[]

const labels = ['T-1', 'T-2'].map((code, index) => `${index}:${code}`);
// string[]

В сигнатуре два потока, и стоит видеть оба. Первый идёт из массива в колбэк: T подсказывает стрелке тип n, хотя ты его не писал. Это контекстная типизация. Второй идёт обратно, из колбэка в результат: что стрелка вернула, то и стало U. Почти каждая сигнатура в этом разделе устроена так же, только потоков больше.

thisArg?: any в карточке тоже есть. Им почти никто не пользуется, и строку можно пропустить. Но слово any запомни: в конце урока оно сломает Ринцвальду вечер.

Promise.then: два параметра и значения по умолчанию

Следующая карточка длиннее. В lib.es5.d.ts это одна строка, здесь она разбита, чтобы её было видно:

interface Promise<T> {
  then<TResult1 = T, TResult2 = never>(
    onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null,
    onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null,
  ): Promise<TResult1 | TResult2>;
}

Те же четыре шага, только новых деталей больше.

  • У параметров типа есть значения по умолчанию: TResult1 = T, TResult2 = never. Если выводить не из чего, TypeScript берёт их. Не передал onrejected, и TResult2 остался never, пустой полкой из урока 05. Объединение с пустой полкой ничего не добавляет: TResult1 | never это TResult1.
  • Колбэк возвращает TResult1 | PromiseLike<TResult1>: значение или промис значения. Если ты вернул промис, TypeScript выводит TResult1 из того, что внутри, поэтому then снимает один слой и Promise<Promise<...>> не получается.
  • Результат Promise<TResult1 | TResult2>: что вернула ветка успеха, или что вернула ветка ошибки.
const length = Promise.resolve('T-1').then((code) => code.length);
// Promise<number>

const wrapped = Promise.resolve(12).then((frame) => Promise.resolve([frame]));
// Promise<number[]>, а не Promise<Promise<number[]>>

const withFallback = Promise.resolve(12).then(
  (frame) => `кадр ${frame}`,
  () => null,
);
// Promise<string | null>

И главная строка карточки, которую обычно проскакивают: reason: any. У промиса один параметр типа, T, и он про успех. Про ошибку в типе нет ничего: что бы ни бросили внутри, в onrejected придёт any. Промис знает, что отдаст, и не знает, как упадёт. Через полчаса эта строка окажется важнее всех остальных.

Перегрузки

В каталоге Библиотеки у одной книги бывает несколько карточек: одна для читального зала, другая на вынос. Библиотекарь перебирает их сверху вниз и выдаёт книгу по первой, которая подошла к твоей просьбе.

У функции так же. Перегрузки это несколько сигнатур над одной реализацией. Вот маленький pipe: значение и цепочка функций, каждая получает результат предыдущей.

export function pipe<A>(a: A): A;
export function pipe<A, B>(a: A, ab: (a: A) => B): B;
export function pipe<A, B, C>(a: A, ab: (a: A) => B, bc: (b: B) => C): C;
export function pipe(a: unknown, ...fns: ReadonlyArray<(value: unknown) => unknown>): unknown {
  return fns.reduce((value, fn) => fn(value), a);
}

const longCode = pipe(
  'T-1',
  (code) => code.length,
  (size) => size > 2,
);
// boolean

Три верхние строки без тела это перегрузки. Четвёртая, с телом, это реализация, и снаружи её не видно: вызов с тремя функциями не соберётся, хотя ...fns принял бы сколько угодно. TypeScript проверяет вызов только по верхним карточкам.

Как выведен boolean? Три аргумента подходят к третьей перегрузке, дальше слева направо. A из первого аргумента: 'T-1' даёт string. Первая стрелка получает code: string, потому что её тип (a: A) => B, а A уже известен; возвращает число, значит, B это number. Вторая стрелка получает size: number и возвращает boolean, это C и результат. Каждая стрелка знает тип своего аргумента только потому, что предыдущая уже вывела свой параметр. Поэтому у pipe параметры типа сцеплены цепочкой: A в B, B в C.

У настоящего pipe из effect/Function двадцать таких перегрузок: от одного значения без функций до значения и девятнадцати функций. Лестница та же, только длиннее.

Перевод с Ук

С перегрузками чаще сталкиваешься в чужом коде, чем в своём. Например, у Array.prototype.reduce три карточки:

interface ReadonlyArray<T> {
  reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: readonly T[]) => T): T;
  reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: readonly T[]) => T, initialValue: T): T;
  reduce<U>(callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: readonly T[]) => U, initialValue: U): U;
}

Собери из кадров список башен-отправителей:

type Frame = { readonly from: string; readonly to: string };
declare const frames: readonly Frame[];

export const towers = frames.reduce((seen, frame) => [...seen, frame.from], []);
towers.ts(4,54): error TS2769: No overload matches this call.
  Overload 1 of 3, '(callbackfn: (previousValue: Frame, currentValue: Frame, currentIndex: number, array: readonly Frame[]) => Frame, initialValue: Frame): Frame', gave the following error.
    Type 'string[]' is missing the following properties from type 'Frame': from, to

Читаем снизу вверх. Последняя строка: массиву строк не хватает полей from и to, чтобы стать кадром. Кто-то ждал от колбэка Frame, а получил string[]. Строка выше называет, кто ждал: перегрузка, где аккумулятор того же типа, что и элемент, и результат тоже Frame. Верхняя строка: не подошла ни одна перегрузка.

Ты целился не в эту карточку, а в третью, с <U>: там аккумулятор своего типа, и U выводится из initialValue. Если перегрузок с ошибками несколько, TypeScript часто показывает одну, где ошибок меньше, и решает, что ты целился в неё. Номер «1 of 3» считается среди перегрузок, которые подошли по числу аргументов, поэтому вторая карточка каталога названа первой. Первая строка ошибки говорит, где сломалось, а последняя говорит, почему.

Почему не подошла третья карточка, видно по пальцу Библиотекаря. Место вывода для U это initialValue, а там []. Пустой массив ничего не говорит о своих элементах, и под strict его тип never[]: массив с пустой полки. U стал never[], а колбэк вернул string[]. Подскажи U сам, раз вывести его не из чего:

export const towers = frames.reduce<string[]>((seen, frame) => [...seen, frame.from], []);
// string[]

Или вспомни, что для «из каждого кадра по полю» есть map: frames.map((frame) => frame.from).

NoInfer: место, откуда выводить нельзя

TypeScript собирает кандидатов для параметра типа со всех мест вывода сразу. Обычно это то, что нужно. Но бывает, что одно место должно только читать T, а не расширять его.

Вот функция, которая берёт первую башню списка, а если список пуст, запасную. Запасная обязана быть одной из списка:

const loose = <T extends string>(towers: readonly T[], fallback: T): T => towers[0] ?? fallback;

const picked = loose(['T-1', 'T-2'], 'T-9');
// 'T-1' | 'T-2' | 'T-9'

T-9 в списке нет, а вызов собрался: fallback тоже место вывода, и TypeScript честно добавил 'T-9' к кандидатам. Ограничение T extends string здесь держит литералы: без него вышел бы весь string.

С TypeScript 5.4 есть NoInfer<T>: «здесь T используй, но не выводи». Тогда T берётся только из списка:

const strict = <T extends string>(towers: readonly T[], fallback: NoInfer<T>): T => towers[0] ?? fallback;

const ok = strict(['T-1', 'T-2'], 'T-2');
// 'T-1' | 'T-2'

const bad = strict(['T-1', 'T-2'], 'T-9');
picker.ts(6,36): error TS2345: Argument of type '"T-9"' is not assignable to parameter of type '"T-1" | "T-2"'.

Этот вид ошибки ты читал в уроке 04. Важнее, где она встала: на 'T-9', а не где-то ниже, когда запасная башня уже уехала в маршрут. В Effect.d.ts NoInfer стоит в десятках сигнатур, часто у предикатов и фильтров: тип элемента выводится из коллекции, а предикат его только читает.

Карточка, которая выпала

Пока ты читаешь reduce вслух, из каталога выскальзывает карточка. Не из lib.es5.d.ts, с другой полки: на ней штамп effect 4.1. Вот что на ней, дословно из node_modules/effect/dist/Effect.d.ts; комментарии убраны, тело интерфейса свёрнуто:

export interface Effect<out A, out E = never, out R = never> extends Pipeable, Inspectable {
  // ...
}

export declare const map: {
  <A, B>(f: (a: A) => B): <E, R>(self: Effect<A, E, R>) => Effect<B, E, R>;
  <A, E, R, B>(self: Effect<A, E, R>, f: (a: A) => B): Effect<B, E, R>;
};

Половину ты уже можешь прочитать.

  • map это константа, у типа которой две сигнатуры вызова в фигурных скобках. Это те же перегрузки, записанные объектом.
  • Вторая строка похожа на Array.map: возьми self и функцию из A в B, отдай Effect<B, E, R>. E и R проходят насквозь нетронутыми: map меняет только значение.
  • Первая строка берёт одну функцию и отдаёт функцию, которая ждёт эффект. Это форма для pipe. Pipeable в объявлении интерфейса приносит эффекту метод pipe с той же лестницей перегрузок.
  • E = never и R = never это значения по умолчанию, как TResult2 = never у then: если не сказано иное, падать нечем и ничего не нужно.

Где тут дыра

Ринцвальд заглядывает через плечо и говорит, что сигнатуры читать необязательно: «тип же выводится сам». У него есть функция, которая превращает ответ башни в крик дежурному:

const shout = (frame: Promise<string>) =>
  frame
    .catch((error) => error.message)
    .then((text) => text.toUpperCase());

И правда работает, на его примере:

console.log(await shout(Promise.reject(new Error('гроза'))));
ГРОЗА

А на соседнем, где башня отвергла промис строкой, как делает половина скорописи города:

console.log(await shout(Promise.reject('гроза')));
TypeError: Cannot read properties of undefined (reading 'toUpperCase')

Прочитай карточку catch, ответ в ней:

catch<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null): Promise<T | TResult>;

reason: any, значит, error.message тоже any, и TResult вывелся как any. Результат Promise<string | any>, а объединение с any это any: одно слово в сигнатуре съело тип целиком. Курсор Ринцвальда показывал Promise<any>, но он не дочитал. Дальше text в then тоже any, и toUpperCase никто не проверил.

Лечится тем, что ты уже знаешь из урока 06: объяви ошибку как unknown и сузь её.

const shout = (frame: Promise<string>) =>
  frame
    .catch((error: unknown) => (error instanceof Error ? error.message : String(error)))
    .then((text) => text.toUpperCase());

Теперь результат Promise<string>, и это правда. Но заметь, кто ставил unknown: ты, руками, потому что прочитал карточку. Сигнатура промиса тебе в этом не помогла. Карточку с Effect в кармане написали, чтобы помогала.

Разминка

Библиотекарь раскрывает каталог на шести строках и ждёт, что ты прочитаешь каждую. Курсор не наводи: пиши тип по сигнатуре.

Практика

Дальше

Ринцвальд забирает свою функцию и уходит чинить, но через час возвращается с журналом башни. В нём десять switch по _tag: сводка, журнал, счётчик, звонок дежурному, и в каждом те же четыре case. «Можно же одну таблицу, тег слева, обработчик справа? Я устал». Можно, если тип таблицы вывести из самих сообщений. Как это сделать, разберём в следующем уроке.