Я кодирую на 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
выдаст ошибку. Приведенный ниже код просто иллюстрирует, что может произойти. Я отредактировал код, чтобы подчеркнуть проблему.
Какой дизайн вы пытаетесь смоделировать? Может быть, композиция и/или дженерики подойдут лучше, чем наследование?
Вопрос не в коде — код представляет собой простую иерархию классов. Я хочу, чтобы меня заставили объявить тип x
в B.do(x)
как number | string
. Сегодня я обнаружил ошибку в своем коде, вызванную тем, что какой-то переопределяющий метод в подклассе имеет слишком узкий тип параметра. И я этим недоволен...
Кажется, что «это функция, а не ошибка», как объясняется здесь:
https://github.com/microsoft/TypeScript/issues/22156
Мне очень грустно: значит, даже со всеми строгими флагами (strict, strictFunctionTypes) у вас может быть безошибочный код, который просто не работает из-за ошибки типа.
"Или, может быть, есть какая-то работа вокруг?" Вы задаете новый вопрос внутри ответа? Это не совсем то, как SO должен работать. Если вы хотите сложить это в свой исходный вопрос, вы должны отредактировать его, чтобы задать этот вопрос. Я был бы рад написать отдельный ответ о бивариантности метода TS и указать на этот возможный обходной путь, где вы используете тип свойства с функциональным значением вместо метода. Как бы вы хотели продолжить? (Также: обратите внимание, что --alwaysStrict
не имеет ничего общего с проверкой типа TS; это просто означает, что "use strict"
выводится в JS.)
Спасибо за ваш комментарий и интерес. Я отредактировал вопрос и ответ, чтобы соблюдать обычаи SO. Спасибо за пример! Буду признателен, если вы напишете более подробное объяснение разницы между полями и методами. Я с удовольствием узнаю, какой будет предложенный способ кодирования и какова исходная причина проблемы.
Чтобы 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;
}
}
В какой части вы ожидаете ошибку? Часть, где вы пишете
override do(x: number): number
, или часть, где вы называете.do("dupa")
? Кстати,(<A>new B())
говорит машинописному тексту: «Игнорируйте типы и считайте, что это A». Это не влияет на время выполнения, оно просто отключает машинописный текст во время компиляции.