Наследование и подтипирование в TypeScript — ошибка?

Я кодирую на TypeScript, и это, кажется, позволяет создавать небезопасный код. Я использую все «строгие» варианты, которые я нашел. Поведение, которое я замечаю, строго против «Наследование подразумевает подтип», как обсуждалось, например, в:

https://stackoverflow.com/questions/50729485/override-method-with-other-argument-types-in-extended-class-typescript

Код, который не вызывает ошибок типа, следующий:

abstract class A {
    abstract do(x: number | string): number;
}

class B extends A {
    override do(x: number): number {
        return x;
    }
}

const a: A = new B();

const x: number = a.do("dupa");

console.info(x);

Я ожидал бы ошибку, как

Error:(7, 14) TS2416: Property 'do' in type 'B' is not assignable to the same property in base type 'A'.
  Type '(x: number) => number' is not assignable to type '(x: string | number) => number'.
    Types of parameters 'x' and 'x' are incompatible.
      Type 'string | number' is not assignable to type 'number'.
        Type 'string' is not assignable to type 'number'.

Вместо этого я получаю консольный вывод «dupa».

Я попытался изменить типы (X, Y) = (число, строка) на другие пары, предполагая, что может быть выполнено какое-то неявное приведение. Но я получаю тот же эффект с другими типами, такими как произвольные, неназначаемые типы X и Y или даже некоторые X и Y=null (я работаю со strictNullChecks).

Кроме того, я способен генерировать ошибку типа Type 'string | number' is not assignable to type 'number'.   Type 'string' is not assignable to type 'number'. Так что в целом такое присвоение не является законным.

Как отмечено ниже, кажется, что «это функция, а не ошибка», см. https://github.com/microsoft/TypeScript/issues/22156

Таким образом, я хотел бы переформулировать вопрос:

Есть ли обходной путь, который заставляет средство проверки типов TypeScript обнаруживать такое отсутствие контравариантности в типах параметров переопределенных методов?

В какой части вы ожидаете ошибку? Часть, где вы пишете override do(x: number): number, или часть, где вы называете .do("dupa")? Кстати, (<A>new B()) говорит машинописному тексту: «Игнорируйте типы и считайте, что это A». Это не влияет на время выполнения, оно просто отключает машинописный текст во время компиляции.

Nicholas Tower 11.04.2023 14:42

Я ожидаю, что часть override do(x: number):number выдаст ошибку. Приведенный ниже код просто иллюстрирует, что может произойти. Я отредактировал код, чтобы подчеркнуть проблему.

mskrzypczak 11.04.2023 14:44

Какой дизайн вы пытаетесь смоделировать? Может быть, композиция и/или дженерики подойдут лучше, чем наследование?

Kangur 11.04.2023 17:18

Вопрос не в коде — код представляет собой простую иерархию классов. Я хочу, чтобы меня заставили объявить тип x в B.do(x) как number | string. Сегодня я обнаружил ошибку в своем коде, вызванную тем, что какой-то переопределяющий метод в подклассе имеет слишком узкий тип параметра. И я этим недоволен...

mskrzypczak 12.04.2023 00:23
Зод: сила проверки и преобразования данных
Зод: сила проверки и преобразования данных
Сегодня я хочу познакомить вас с библиотекой Zod и раскрыть некоторые ее особенности, например, возможности валидации и трансформации данных, а также...
Как заставить Remix работать с Mantine и Cloudflare Pages/Workers
Как заставить Remix работать с Mantine и Cloudflare Pages/Workers
Мне нравится библиотека Mantine Component , но заставить ее работать без проблем с Remix бывает непросто.
Угловой продивер
Угловой продивер
Оригинал этой статьи на турецком языке. ChatGPT используется только для перевода на английский язык.
TypeScript против JavaScript
TypeScript против JavaScript
TypeScript vs JavaScript - в чем различия и какой из них выбрать?
Синхронизация localStorage в масштабах всего приложения с помощью пользовательского реактивного хука useLocalStorage
Синхронизация localStorage в масштабах всего приложения с помощью пользовательского реактивного хука useLocalStorage
Не все нужно хранить на стороне сервера. Иногда все, что вам нужно, это постоянное хранилище на стороне клиента для хранения уникальных для клиента...
Что такое ленивая загрузка в Angular и как ее применять
Что такое ленивая загрузка в Angular и как ее применять
Ленивая загрузка - это техника, используемая в Angular для повышения производительности приложения путем загрузки модулей только тогда, когда они...
1
4
86
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Кажется, что «это функция, а не ошибка», как объясняется здесь:

https://github.com/microsoft/TypeScript/issues/22156

Мне очень грустно: значит, даже со всеми строгими флагами (strict, strictFunctionTypes) у вас может быть безошибочный код, который просто не работает из-за ошибки типа.

"Или, может быть, есть какая-то работа вокруг?" Вы задаете новый вопрос внутри ответа? Это не совсем то, как SO должен работать. Если вы хотите сложить это в свой исходный вопрос, вы должны отредактировать его, чтобы задать этот вопрос. Я был бы рад написать отдельный ответ о бивариантности метода TS и указать на этот возможный обходной путь, где вы используете тип свойства с функциональным значением вместо метода. Как бы вы хотели продолжить? (Также: обратите внимание, что --alwaysStrict не имеет ничего общего с проверкой типа TS; это просто означает, что "use strict" выводится в JS.)

jcalz 11.04.2023 15:20

Спасибо за ваш комментарий и интерес. Я отредактировал вопрос и ответ, чтобы соблюдать обычаи SO. Спасибо за пример! Буду признателен, если вы напишете более подробное объяснение разницы между полями и методами. Я с удовольствием узнаю, какой будет предложенный способ кодирования и какова исходная причина проблемы.

mskrzypczak 11.04.2023 16:15
Ответ принят как подходящий

Чтобы TypeScript был действительно надежным или типобезопасным, ему нужно было бы контравариантно сравнивать все параметры функций и методов, чтобы переопределения методов могли только расширять, но не сужать типы параметров. (См. Разница между дисперсией, ковариацией, контравариантностью и бивариантностью в TypeScript для более подробного обсуждения дисперсии.)

Но TypeScript не совсем надежен и не предназначен; см. Дизайн TypeScript, не являющийся целью # 3, где говорится, что целью не является «применить надежную или «доказуемо правильную» систему типов», а фактическая цель состоит в том, чтобы «найти баланс между правильностью и производительностью».

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

Это по нескольким причинам. Одна из причин заключается в том, что большинство разработчиков хотят рассматривать Array<T> как ковариантный в T, поэтому Array<Dog> также является Array<Animal>:

interface Animal { move(): void; }
interface Dog extends Animal { bark(): void; }
const dogs: Dog[] = [];
const animals: Animal[] = dogs; // okay
animals.forEach(a => a.move()); // okay

Это неправильно, потому что если вы push() a Cat на Array<Animal>, который на самом деле является псевдонимом Array<Dog>, вы что-то сломали:

interface Cat extends Animal { meow(): void; }
const cat: Cat = { move() { }, meow() { } };
animals.push(cat); // okay
dogs.forEach(d => d.bark()) // ERROR AT RUNTIME

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

const dog: Dog = { move() { }, bark() { } };
const dogCage = { resident: dog };
const animalCage: { resident: Animal } = dogCage; // okay
animalCage.resident = cat; // switcharoo
dogCage.resident.bark(); // ERROR AT RUNTIME AGAIN

Таким образом, заставить методы быть правильными, когда свойства не являются чем-то вроде «слишком мало, слишком поздно» для системы типов. Можно было бы ужесточить ситуацию, заставив свойства быть инвариантными или разрешив ковариацию только для свойств readonly и т. д., но это оказывается очень раздражающим для подавляющего большинства случаев использования, когда люди поступают правильно.

Тем не менее, они рассматривали возможность применения этого для методов с --strictFunctionTypes. Но, к сожалению, согласно документации, «при разработке этой фичи мы обнаружили большое количество изначально небезопасных иерархий классов, в том числе и в DOM». Таким образом, некоторые существующие иерархии нативного JavaScript class нарушают это правило, и его принудительное применение поставит TypeScript в неблагоприятное положение, так или иначе аннулировав нативный JavaScript.

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


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

interface A {
    do: (x: number | string) => number;
}

class B implements A {
    do(x: number): number { // error!
        return x;
    }
}

Здесь я превратил A в interface, потому что вам не разрешено переопределять функциональное свойство class с помощью метода, даже если это abstract (см. microsoft/TypeScript#51261). Если вам нужно, чтобы родитель был классом, вы могли бы просто использовать свойства с функциональным значением вместо методов, хотя они меняются независимо от того, существуют ли они в экземплярах или прототипе:

abstract class A {
    abstract do: (x: number | string) => number;
}

class B extends A {
    override do = function (x: number) { // error            
        return x;
    }
}

Площадка ссылка на код

Другие вопросы по теме