Предисловие

Написать заметку меня побудила статья Как устроена система типов typescript и собственный опыт.

Я обратил внимание, что не всегда понимал семантику "extends" в разных контекстах и влияние настроек языка.

В результате, то что меня смущало, оказалось рабочим поведением языка, но при этом непредсказуемым и небезопасным.

Введение

Для демонстрации я выбрал простое объединение string | number, которое буду помещать в различные контексты.

Пример 1:

type TA = number | string
type TB = string

type E1 = TA extends TB ? true : false // false
type E2 = TB extends TA ? true : false // true

export const example1 = () => {
  let a: TA = 100
  let b: TB = 'value 100'

  b = a
  a = b

  return true
}

Когда мы имеем дело с примитивными типами наше объединение будет выступать в определенном смысле супер типом для string или number.
И по описанному в статье, в примере 1 мы будем иметь некорректное нисходящее приведение b = a и корректное восходящее приведение a = b.
Пока все понятно, и предсказуемо.

Функции

Вариант 2:

type TC = (a: number | string) => void
type TD = (a: string) => void

type E3 = TC extends TD ? true : false
type E4 = TD extends TC ? true : false

let c: TC = a => undefined
let d: TD = a => {
  if (typeof a !== 'string') {
    throw 'error a in d'
  }
}

const example2 = () => {
  c = d
  d = c

  return true
}

Вроде по смыслу ничего не поменялось, но ts перестал работать как ожидаем. Здесь у нас работает явно противоречащие принципу небезопасное присваивание.
За такие финты ушами отвечает compilerOptions->strict установленный в true. А так же может быть использована отдельная опция strictFunctionTypes.
Включение этих флажков решает проблему и мы получаем предупреждение о небезопасном присваивании.

Объекты

Адаптируем функцию выше как метод объекта.

Вариант 3:

interface IA {
  setValue(a: number | string): void
}

class CB implements IA {
  setValue(a: string) {
    if (typeof a !== 'string') {
      throw 'error a in CB'
    }
  }
}

const example3 = () => {
  const oIA: IA = new CB
  oIA.setValue(100) // Error: thrown: "error a in CB"

  return true
}

Наш конфиг уже имеет флаг strict->true. Но никаких ошибок вида ниже мы не получаем:

TS2322: Type TD is not assignable to type TC
  Types of parameters a and a are incompatible.
    Type string | number is not assignable to type string
      Type number is not assignable to type string

Это как раз тот самый момент, который и вызвал у меня вопросы.

Ответ нашелся в документации ts и звучит примерно так:

Во время разработки этой функции мы обнаружили большое количество изначально небезопасных иерархий классов, включая некоторые из них в DOM. В связи с этим настройка применяется только к функциям, написанным в синтаксисе функций, а не в синтаксисе методов

Обновлено:
Проблему можно решить, использовав стрелочную функцию:

interface IA {
  setValue: (a: number | string) => void
}

Заключение

Выводов оказалось два.

  1. Обязательно устанавливайте флажок strict. Он подключает дополнительные проверки типов.

  2. При реализации интерфейсов, и работе с объектами корректность соблюдения принципа подстановки Барбары Лисков ложится на плечи разработчиков.

    • использовать стрелочную форму записи метода

    • следить за импортированными типами

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


  1. AlexandroppolusX
    30.09.2024 14:48
    +2

    1. При реализации интерфейсов, и работе с объектами корректность соблюдения принципа подстановки Барбары Лисков ложится на плечи разработчиков.

    Просто надо по возможности использовать "стрелочную" форму, тогда всё будет окей.

    interface IA {
      setValue: (a: number | string) => void;
    }


    1. Vitaly_js Автор
      30.09.2024 14:48

      Спасибо. Актуальное замечание. Добавил в заметку.


      1. AlexandroppolusX
        30.09.2024 14:48
        +1

        Ага, спасибо. На всякий допишу тут ещё 2 поинта (если вдруг у читателей возникнут вопросы). Во первых, для типов/интерфейсов нет разницы в поведении между стрелочной и "обычной" формой, как для объектов js (значения this, super, и т.д.), и реализовывать в классе можно и так и сяк, что видно в примере. Во вторых, если нужны перегрузки, то, например, аналогом такого для "обычной" формы

        interface IA {
          setValue(a: number | string): void;
          setValue(a: boolean): boolean;
        }

        будет такой код

        interface IA {
          setValue: {
            (a: number | string): void;
            (a: boolean): boolean;
          };
        }


        1. Vitaly_js Автор
          30.09.2024 14:48

          Я вот про себя тоже сразу подумал, что надо бы проверить не страдает ли кол-сигнатура тоже какой-нибудь такой фигней.

          Сам я еще кол-сигнатуры не перегружал, но выглядит как рабочее решение.


  1. aleks4hour
    30.09.2024 14:48

    запись метода как стрелочная функция так как метод становится свойством this а не прототипа, поэтому это абсолютно разная семантика. typescript достаточно коряво и неполностью поддерживает классы, работать с классами больно


    1. Vitaly_js Автор
      30.09.2024 14:48

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


      1. aleks4hour
        30.09.2024 14:48

        это хак, как ни крути


        1. Vitaly_js Автор
          30.09.2024 14:48

          Почему? Такое же решение как и то с которым столкнулся я. Если на то пошло, то поведение языка на данный момент само по себе является хаком.


    1. meonsou
      30.09.2024 14:48

      Чего конкретно тайпскрипт не поддерживает в классах? Мне приходит в голову только return в конструкторе, которым в принципе крайне редко пользуются даже в жс.


      1. aleks4hour
        30.09.2024 14:48

        наследовование со статическими методами и генериками например, возвращаю this часто из конструктора например прокси


        1. meonsou
          30.09.2024 14:48

          наследовование со статическими методами и генериками

          Не понял о чём речь. Наследование есть, статические методы есть, наследование статических методов есть. Генерики там на месте.