По аналогии с принципом LSP из ООП, в Typescript, при передаче функций как объектов стоит придерживаться следующего принципа:

Принимая колбэк с меньшим числом аргументов, оборачивайте его, прежде, чем передавать его далее в качестве колбэка с большим числом аргументов.

Откуда появляется проблема?

Те, кто испытывает тёплые чувства по отношению к функциональному стилю, любят обращаться с функциями как объектами. Например, нам нужно отформатировать деньги:

const formatRubles = (amount: number) => `${amount} ₽`;
const amounts = [100, 200, 300].map((amount) => formatRubles(amount));

Можно это написать немного короче:

const amounts = [100, 200, 300].map(formatRubles);

Получается красивый, легко читаемый код без множества скобок. А Typescript делает здесь хорошую работу, проверяя, действительно ли вы можете так сделать с точки зрения типов.

Но не всегда. И дальше я разберу два примера:

  1. На компонентах React JS — потому что эту проблему легко получить при передаче обработчиков через дерево компонентов.

  2. На примере обычных функций.

Пример на React JS

Те, кто работают с React JS, легко представят следующий пример.

Допустим, мы сделали визуальный компонент LogButton для кнопки логирования. И используем его в компоненте App в нескольких местах. Я убрал лишнее, чтобы не загромождать код:

// log-button.tsx
interface Props {
  onClick: () => void;
}

const LogButton: React.FC<Props> = (props) => (
  <button className="log-button" onClick={props.onClick}>
    Log
  </button>
);

// app.tsx
export default function App() {
  const logConsole = (s: string) => console.log("[LOG]", s);
  return (
    <div>
        <LogButton onClick={() => logConsole("Something happened")} />
        {/* Ещё какой-то JSX */}
        <LogButton onClick={() => logConsole("Something happened")} />
    </div>
  );
}

Ссылка на codesandbox.

Обратите внимание, что проп onClick объявлен без аргументов, т.к. мы считаем LogButton бизнесовым компонентом и не хотим выставлять наружу ивент, приходящий из button.

При клике по LogButton получаем ожидаемое:

[LOG] Something happened

Проблема же появляется, когда кто‑то будет рефакторить компонент App и решит сделать «Something happened» значением по‑умолчанию для logConsole. А раз у logConsole теперь есть аргумент по‑умолчанию, а LogButton обещает не передавать в onClick никаких аргументов, то почему бы не передать logConsole в LogButton как объект?

export default function App() {
  const logConsole = (s: string = "Something happened") =>
    console.log("[LOG]", s);
  return (
    <div>
        <LogButton onClick={logConsole)} />
        {/* Ещё какой-то JSX */}
        <LogButton onClick={logConsole} />
    </div>
  );
}

Красиво! Но что мы увидим в консоли, вместо "[LOG] Something happened"?

[LOG] {
  _reactName: 'onClick', 
  _targetInst: null, 
  type: 'click', 
  nativeEvent: PointerEvent, 
  target: HTMLButtonElement, 
  …
}

Так получилось, потому что button передаёт в обработчик onClick аргумент с описанием события. А Typescript здесь не видит никакой ошибки, потому что позволяет передать в качестве колбэка, как функцию с меньшим числом аргументов, так и функцию с большим числом аргументов (если лишние аргументы являются опциональными).

Проследим передачу logClick с точки зрения Typescript.

Сначала число аргументов logClick “уменьшилось”, когда она стала пропом для LogButton и это нормально — потому что LogButton даёт обещание, что его onClick не нужно никаких аргументов. А у logClick теперь нет обязательных аргументов (s имеет значение по-умолчанию, а значит не обязателен).

Затем logClick передаётся в качестве обработчика button onClick, у которого больше аргументов. И для Typescript это тоже нормально (см. FAQ здесь и здесь). Иначе, трюк с formatRubles выше не работал бы, и было бы неудобно — у formatRubles только один аргумент, а map принимает колбэк с тремя аргументами.

Решить проблему довольно легко. Например, везде оборачивать функции:

// log-button.tsx
interface Props {
  onClick: () => void;
}

const LogButton: React.FC<Props> = (props) => (
  <button className="log-button" onClick={() => props.onClick()}>
    Log
  </button>
);

// app.tsx
export default function App() {
  const logConsole = (s: string = "Something happened") => 
    console.log("[LOG]", s);
  return (
    <div>
        <LogButton onClick={() => logConsole()} />
        {/* Ещё какой-то JSX */}
        <LogButton onClick={() => logConsole()} />
    </div>
  );
}

Но это не красиво! Хочется понять, где это делать обязательно, а где — нет.

Ведь, на самом деле, виноват разработчик компонента LogButton. Он объявил функцию onClick, как не имеющую аргументов (в интерфейсе Props), а передаёт её в качестве обработчика с большим числом аргументов:

// log-button.tsx
interface Props {
  onClick: () => void;
}

const LogButton: React.FC<Props> = (props) => (
  <button className="log-button" onClick={props.onClick}>
    Log
  </button>
);

Т.е. он нарушил контракт своего компонента. Именно ему надо было использовать указанный принцип и обернуть функцию:

// log-button.tsx
interface Props {
  onClick: () => void;
}

const LogButton: React.FC<Props> = (props) => (
  <button className="log-button" onClick={() => props.onClick()}>
    Log
  </button>
);

Потому что кто-то "сверху" мог передать в пропсы функцию с большим числом аргументов.

Пример на простом Typescript

Ещё один пример.

Допустим, мы написали странный форматер номеров платёжных карт, который принимает в качестве аргумента функцию, берущую часть строки, и возвращает откуда-то взятый маскированный номер карты:

interface SliceOptions {
  start: number;
}

type SlicerFunc = (s: string, options: SliceOptions) => string;

const getCardMask = (slicer: SlicerFunc) => {
  const userCard = "1234 5678 9012 3456";
  const visiblePart = slicer(userCard, { start: 15 });
  return `**** **** **** ${visiblePart}`;
};

И модуль, который его вызывает и использует упрощённый тип SimpleSlicer для колбэка:

type SimpleSlicer = (s: string) => string;

const sliceV1: SimpleSlicer = (s) => s.slice(15);

const mistakenFunc = (slice: SimpleSlicer) => getCardMask(slice);

console.log(mistakenFunc(sliceV1));

Ссылка на Typescript Playground.

В консоли будет:

**** **** **** 3456

Всё хорошо. Но затем кто-то решил отрефакторить функцию sliceV1:

const sliceV2: SimpleSlicer = (s, start = 15) => s.slice(start);

const mistakenFunc = (slice: SimpleSlicer) => getCardMask(slice);

console.log(mistakenFunc(sliceV2));

Typescript не ругается. В консоль при этом выводится:

**** **** **** 1234 5678 9012 3456

Безопасность нарушена! Потому что в start вместо ожидаемого значения 15 попал объект { start: 15 }

Кто виноват? Автору mistakenFunc следовало обернуть slice, раз он видит, что getCardMask принимает обработчик с большим количеством аргументов:

const sliceV2: SimpleSlicer = (s, start = 15) => s.slice(start);

const mistakenFunc = (slice: SimpleSlicer) => getCardMask((s) => slice(s));

console.log(mistakenFunc(sliceV2));

Тогда всё будет хорошо.

Выводы

Соблюдайте контракт и следите, чтобы полученному колбэку передавалось не больше аргументов, чем обещаете. Другими словами:

Принимая колбэк с меньшим числом аргументов, оборачивайте его, прежде, чем передавать его далее в качестве колбэка с большим числом аргументов.


UPD. Спасибо @Alexandroppolus — я убрал опциональность в типах SliceOptions и SlicerFunc.

Действительно, когда объявляешь тип колбэка, то в документации Typescript есть следующая рекомендация:

Когда пишешь тип функции-колбэка, никогда не делай параметр опциональным, если только не намереваешься вызывать этот колбэк без этого параметра.

Потому что, объявляя колбэк:

type SlicerFunc = (s: string, options?: SliceOptions) => string;

Мы заставляем пользователя getCardMask обрабатывать кейс, когда options undefined:

getCardMask((s, options) => s.slice(options?.start));

Хотя getCardMask всегда вызывает slicer-колбэк с этим параметром. Т.е. появляется ненужное усложнение.

Комментарии (5)


  1. Alexandroppolus
    10.12.2023 21:10

    Немного оффтоп, но ещё стоит добавить, что типизация параметров колбэка в идеале должна точно соответствовать его вызову. Например, сейчас для getCardMask более подошел бы такой колбэк:

    interface SliceOptions { start: number; }
    
    type SlicerFunc = (s: string, options: SliceOptions) => string;

    Теперь мы можем передать в getCardMask так же функцию, у которой обязателен второй параметр и поле start в нем (равно как и функцию, у которой это всё необязательно), а поле end - необязательное, но что-то кроме number. Иными словами, снизили требования к пользователю getCardMask до минимально возможных.


    1. anton_nix Автор
      10.12.2023 21:10

      Спасибо за ценное замечание, дополнил статью!


  1. meonsou
    10.12.2023 21:10

    Занятный способ избежать таких проблем это использовать тип void вместо отсутствующих аргументов, хотя это и не слишком практично.

    interface Props {
      onClick: (..._: void[]) => void
    }

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

    Всё ещё можем вызывать без аргументов:

    (
      <button onClick={() => onClick()}>
        Log
      </button>
    )

    Но уже не можем передать туда где они есть:

    (
      <button onClick={onClick}> // Ошибка, MouseEvent<...> не совместим с void
        Log
      </button>
    )

    К сожалению, этот подход не сочетается с дефолтными аргументами, как в примере из статьи.

    const logConsole = (s: string = "asd") => console.log("[LOG]", s)
    
    (
      <LogButton onClick={logConsole} /> // Ошибка, тип void не совместим с string | undefined
    )

    Здесь можно либо сделать аргумент void | string, либо тоже обернуть функцию. Первого я делать не советую, так как тип void обозначает значение, которое не должно быть использовано, и в нём может лежать что угодно.


    1. anton_nix Автор
      10.12.2023 21:10

      Это тоже отличный комментарий, и кому-то это может пригодится.

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


  1. vsarmaev
    10.12.2023 21:10

    Всё хорошо, всё правильно, всё гладко.

    Но Вас трудно понимать: получается неинтуитивно, так как Вы используете IT-суржик вместо родного языка.

    Использование суржика вместо родного языка - это не необходимость, не насущная потребность, не знак мастерства и не отметка принадлежности к профессиональной группе с устоявшимся словарным запасом среды. Просто напросто это интеллектуальная лень. Как сон разума пораждает чудовищ, так и лень разума пораждает мелких противных гремлинов - бестий не(до)понимания и извращения смысла (и оба мучимся мы с ним).