Мне нужно определить тип в TypeScript, который выражает «любой подтип T, но не T», где T — формальный параметр типа универсального типа. Позвольте мне привести пару вариантов использования, чтобы мотивировать эту необходимость.
type AnySubtypeOf<T> = ? // the strict subtype expression I am looking for
class FamilyTree<T> {
parent: T;
children: AnySubtypeOf<T>[];
constructor(familyTree: FamilyTree<T>) {
this.parent = familyTree.parent;
this.children = familyTree.children;
}
}
Семантика FamilyTree запрещает любому ребенку быть своего рода родителем. Вот почему мне нужно ограничить типы объектов, которые можно поместить в массив children, чтобы они были любым подтипом T, но не T (т. е. строгим подтипом T).
Моя цель — улучшить опыт разработки при создании экземпляров типа FamilyTree. Скажем, у нас есть следующие классы:
class A {
x: number;
constructor(x: number) { this.x = x; }
}
class B extends A {
y: number;
constructor(x: number, y: number) { super(x); this.y = y; }
}
class C {
x: number;
z: number;
constructor(x: number, z: number) { this.x = x; this.z = z; }
}
class D {
w: string;
constructor(w: string) { this.w = w; }
}
Вот ожидаемые результаты компиляции для нескольких экземпляров FamilyTree:
const a = new A(1);
const b = new B(2, 3);
const c = new C(4, 5);
const d = new D("Hey!");
new FamilyTree<A>({ parent: a, children: [ b, c ] }); // ✅ No TS error
new FamilyTree<A>({ parent: a, children: [ b, d ] }); // ❌ TS error since d is not a subclass of A
new FamilyTree<A>({ parent: a, children: [ b, a ] }); // ❌ TS error since a is not an strict subclass of A
Вот пример проблемы, с которой я столкнулся в контексте Monguito, легкого полиморфного абстрактного репозитория, который позволяет легко и быстро определять пользовательские репозитории для приложений Node.js. Monguito построен на основе Mongoose, но и Mongoose, и Node.js являются деталями реализации для целей этой статьи.
Что действительно важно, так это то, что разработчик любого пользовательского репозитория должен указать некоторые данные о модели предметной области, обрабатываемой его репозиторием. Эти данные состоят из типа и схемы каждого объекта, составляющего модель. Вот пример модели книги:
class Book {
readonly id?: string;
readonly title: string;
readonly isbn: string;
constructor(book: Book) {
this.id = book.id;
this.title = book.title;
this.isbn = book.isbn;
}
}
class PaperBook extends Book {
readonly edition: number;
constructor(paperBook: PaperBookType) {
super(paperBook);
this.edition = paperBook.edition;
}
}
class AudioBook extends Book {
readonly hostingPlatforms: string[];
constructor(audioBook: AudioBookType) {
super(audioBook);
this.hostingPlatforms = audioBook.hostingPlatforms;
}
}
А вот определение MongooseBookRepository, пользовательского репозитория, который выполняет операции с базой данных над объектами, составляющими модель книги:
class MongooseBookRepository
extends MongooseRepository<Book>
implements BookRepository {
constructor() {
super({
type: Book,
schema: BookSchema,
subtypes: [
{ type: PaperBook, schema: PaperBookSchema },
{ type: AudioBook, schema: AudioBookSchema },
],
});
}
}
Как видите, иерархия модели книги определяет корневой объект Book и два подтипа (листья), то есть PaperBook и AudioBook. Семантика модели книги должна быть достаточно строгой, чтобы не допускать объявления корневого объекта Book или любого объекта, не связанного с Book, в качестве подтипа, поскольку они могут вызвать ошибки выполнения во время выполнения, особенно если Book объявлен абстрактным (но это также может быть конкретный, экземплярный тип). Более того, в контексте модели книги Book не может быть одновременно корнем и листом модели книги, как вы можете согласиться.
Здесь дело становится сложнее. MongooseRepository — главный актив Монгито. Это абстрактный репозиторий, который включает в себя набор шаблонных операций CRUD, от которых должен унаследовать любой пользовательский репозиторий, чтобы извлечь из него выгоду. Учитывая все это, вот упрощенный снимок MongooseRepository (полное определение можно найти здесь):
abstract class MongooseRepository<T>
implements Repository<T> {
private readonly domainTree: DomainTree<T>;
protected constructor(
domainModel: DomainModel<T>,
protected readonly connection?: Connection) {
this.domainTree = new DomainTree(domainModel);
}
}
Единственные недостающие части во всей этой головоломке — это DomainModel и DomainTree. Первый закодирован как интерфейс TS, а второй представляет собой класс реализации такого интерфейса (не имеет значения для этого варианта использования, но вы можете увидеть его реализацию здесь). Вот определение, которое мне нужно для DomainModel:
type AnySubtypeOf<T> = ? // the strict subtype expression I am looking for
interface DomainModel<T> {
type: T;
schema: Schema<T>;
subtypes?: DomainModel<AnySubtypeOf<T>>[];
}
Я также упростил исходное определениеDomainModel в иллюстративных целях. Schema — это тип Mongoose, который также не имеет отношения к этому варианту использования. Важно то, что формальный параметр типа DomainModel в subtypes должен выражать «любой строгий подтип T», чтобы соответствовать вышеупомянутой семантике Монгито. Более того, общий характер MongooseRepository, предназначенный для реализации любого типа пользовательского репозитория, запрещает использование любого вспомогательного параметра формального типа в DomainModel.
Я понимаю, что TypeScript явно не поддерживает экзистенциальную количественную оценку, что затрудняет решение моей проблемы.
Более того, система типов TypeScript (только) основана на структурном подтипировании по веской причине: гибкость. Это означает, что любой объект JS, который включает в себя те же свойства, что и другой объект, который должен быть корнем в иерархии типов, на самом деле семантически равен, несмотря на то, что они могут иметь разные имена. Точно так же, как (безымянный) объект { x: number } семантически равен классу A в моем первом варианте использования. Вот почему структурное подтипирование также известно как статическое утиное типирование (помните фразу «если оно ходит как утка и крякает как утка, то это должна быть утка»).
Я не эксперт по теории типов, но считаю, что обеспечить строгое подтипирование в языке, основанном на системе номинальных типов, проще (или я должен даже сказать, что это возможно?).
Учитывая все это, мне кажется, что я не смогу обеспечить строгую подтипизацию в своих случаях использования. Однако, если это так, может ли кто-нибудь подтвердить мои подозрения и рассуждения или объяснить мне, как выразить строгое подтипирование в любом из моих вариантов использования?
Большое спасибо! 👋
Когда вы редактируете , пожалуйста, также сделайте это минимальным воспроизводимым примером , чтобы вы могли определить A, B, C и D для нас и исправить любые опечатки в вашем коде. Из этого вопроса трудно сказать, действительно ли вы понимаете структурную типизацию и чем она отличается от номинальной типизации. Идея «автономного класса» не имеет особого смысла в TypeScript. Я могу написать class X { a = 1 } class Y { a = 1; b = 2 }, и Y extends X верно, несмотря на то, что Y является «автономным». Итак, я думаю, нам понадобится существенная правка, чтобы прояснить этот вопрос.
В том виде, в котором это предлагается в настоящее время, это не похоже на возможное, поскольку TypeScript использует структурную типизацию в отличие от номинальной, что означает, что если бы ваш класс D имел те же свойства, что и B, он все равно прошел бы. Лично я не могу придумать подходящего варианта использования того, что вы пытаетесь сделать. Действительно ли лучше иметь children типа (B | C)[], чем A[]? Более того, если вам не нужны экземпляры A, как насчет того, чтобы сделать его абстрактным классом?
Спасибо за редактирование, но все же, что на самом деле пойдет не так, если вы позволите new Parent<A>({ content: a, children: [ b, a ] });? Почему это имеет значение? Ваш минимально воспроизводимый пример должен показывать реальную проблему, иначе единственный ответ, который вы получите, будет «это невозможно, и вам не следует пытаться это сделать». Как сказано выше, вы можете сделать A абстрактным, но это не повлияет на Parent.
Потрясающие комментарии, спасибо @jcalz и @moonstar-x! Я намеревался предоставить минимальную, полную, воспроизводимую, но в то же время более простую иллюстрацию проблемы, с которой я столкнулся в Monguito. В любом случае, цель состоит в том, чтобы улучшить DevEx и отобразить ошибки компиляции TS в вышеупомянутых сценариях создания экземпляров; по определению Parent не может знать, какие строгие подтипы Parent могут T существовать. Если это невозможно, учитывая семантику TS, дайте мне знать, мне было бы очень хорошо :)
Извините, я не понимаю. Система типов TS не позволяет легко представлять «строгие» подтипы, и я до сих пор не видел минимального воспроизводимого примера , где это имеет значение. Не могли бы вы отредактировать, чтобы показать вариант использования, в котором важно, чтобы Parent<A> принимал экземпляр A? Почему это должен быть подкласс? Что конкретно идет не так? Все, что я слышу из комментариев здесь, это то, что вы хотите, чтобы произошла ошибка, а не почему.
Извиняюсь, @jcalz, мне, честно говоря, трудно, приятель. Как я уже сказал, я не хотел приводить весь вариант использования, над которым работаю, поскольку в противном случае этот пост был бы километровым. В любом случае, поскольку вы, кажется, мотивированы (спасибо за это! :), вы можете увидеть реальный вариант использования здесь. В любом случае, я всегда считал StackOverflow подходящей платформой для того, чтобы задавать теоретические вопросы по языкам программирования; Я сам оценил многие из них :D.
Хм, я не прошу «весь» вариант использования, а только тот, который мотивирует ваш вопрос. Mongoose/nestjs мало что для меня значит, поэтому я не уверен, какую пользу я получу от этой ссылки. Если вы не можете отредактировать приведенный выше код, чтобы продемонстрировать, почему было бы плохо разрешить там экземпляр A, тогда я не знаю, что сказать. Ответ «Вы не можете этого сделать, и я не знаю, почему вас это волнует» — это не то, на что я хочу тратить время. Если бы я мог увидеть, что что-то действительно идет не так, возможно, я мог бы сказать: «О, вот как вы это делаете», потому что я понимаю вариант использования. Но я думаю, что мы уже достаточно гуляли туда-сюда 🤷♂️
Утро @jcalz. Большое спасибо за то количество времени, которое вы уже потратили на мой вопрос, и прошу прощения, если вы почувствовали упрямство с моей стороны; Вчера мой мозг был настолько пережарен, что я не мог по-настоящему придумать лаконичный способ выразить реальный вариант использования, над которым я работаю. Теперь, когда я чувствую себя свежо, я считаю, что мне удалось это сделать :D. Я написал это внизу поста. Надеюсь, еще не поздно оставить последний отзыв с вашей стороны, я очень ценю ваш опыт в TS и готовность мне помочь :). Пожалуйста, не стесняйтесь сообщать мне о любых сомнениях/беспокойствах/несогласии по поводу нового варианта использования. Спасибо!
Тот факт, что вам нужно, чтобы подтипы были конструируемыми, в то время как родительский тип может быть абстрактным, можно смоделировать. См. ссылку на эту игровую площадку. Но все же кажется, что вас не волнуют подтипы так, как вы утверждаете; все, что вы говорите, это «это не имеет смысла», чего я просто не вижу, извините. См. нижнюю часть игровой площадки, где я показываю, что вы можете иметь абстрактный родительский класс и конкретный подкласс, типы экземпляров которых идентичны друг другу; если вы не хотите запретить такой конкретный подкласс, то то, о чем вы просите, на самом деле не то, что вам нужно.
(см. предыдущий комментарий.) Итак, как нам действовать? Я еще не видел варианта использования, который бы гарантировал то, что вы утверждаете, хотите, и ваш основной вариант использования, похоже, просто хочет провести различие между абстрактными и конкретными конструкторами, что имеет очень мало общего с заданным вопросом. Это все больше и больше похоже на проблему XY. Может быть, вы могли бы отредактировать , чтобы задать вопрос о вашем реальном варианте использования, сделать его минимально воспроизводимым примером подходящим для работы в IDE (поэтому объявляйте все, что вы используете) и не поднимайте тему «строгих подтип»? Или вы можете оставить все как есть, и я отключусь; меня в любом случае устраивает.
При всем уважении, я думаю, что вы не совсем понимаете суть вопроса. Речь идет не о моделировании иерархии классов TS с абстрактным корневым типом и конкретными типами листьев; речь идет просто о запрете спецификации иерархии классов, в которой корневой тип также включен в качестве листа. Почему? Потому что это семантика Монгито; Как я уже сказал, плохо определенные модели предметной области приведут к провалу Monguito. Хотя я сделал все, что мог, вы настаиваете на том, что упускаете «реальный вариант использования». Ну, я тоже считаю, что это знак того, что нам следует прекратить этот разговор здесь 🤷♂️. В любом случае спасибо за ваше время.
Я за Moonstar-x, похоже, вы не понимаете структурную и номинальную типизацию. И я согласен с jcalz, что это почти наверняка проблема xy, и есть лучший способ смоделировать ограничения, которые вы пытаетесь выразить. Я ценю уровень усилий, которые вы вложили в это, и вашу готовность принять участие, но когда эксперты говорят вам, что ваш подход не имеет смысла, обычно хорошей идеей будет сделать шаг назад и переосмыслить весь свой подход.
Спасибо, Джаред. Я очень открыт для обучения у экспертов. Однако, если это проблема XY, возможно, будут направлены вопросы типа «Что вы подразумеваете под…?» вместо «Я не понимаю, что вы имеете в виду» помогло бы (если бы я был экспертом, я бы не задал свой вопрос, верно?). В любом случае, положительная сторона всех этих разговоров заключается в том, что рассуждения о строгом подтипировании в структурно типизированном языке, таком как TS, не имеют смысла или невозможны (звучит ли это как разумный ответ на мой первоначальный вопрос, кстати?); в любом случае, меня это устраивает, и я ценю усилия вас троих :)
@JosuMartinez «рассуждения о строгом подтипировании в структурно типизированном языке, таком как TS, не имеют смысла или невозможны», это чрезвычайно радикальное утверждение, которое не соответствует действительности. Вероятно, было бы гораздо справедливее сказать, что «моделирование этой конкретной проблемы таким конкретным способом не подходит для структурно типизированного языка». Если вы имеете в виду язык, в котором можно выразить это ограничение в системе типов, вероятно, было бы хорошей идеей включить его пример, но ответ на заданный вами вопрос, вероятно, по-прежнему будет звучать так: «Вы не можете сделать именно так в Typescript».
Привет, Джаред, возможно, тебе захочется увидеть решение, которое я только что предложил для моего первого варианта использования. Это прекрасно работает для иерархий классов! 🥳






Короткий ответ на мой вопрос заключается в том, что строгое подтипирование изначально не поддерживается в TypeScript и что гибкость определения типа, обеспечиваемая системой типов языка (т. е. структурной типизацией), препятствует тривиальным обходным путям дальнейшего ограничения подтипирования.
Однако если мы хотим переключиться на системы номинативных типов, есть обходной путь, который может помочь нам, приложив некоторые дополнительные усилия, частично добиться строгого подтипирования. Идея состоит в том, чтобы внедрить брендинг (также известный как типографирование).
Мы можем добавить к нашим типам некоторую дополнительную информацию, чтобы дифференцировать их псевдономинальным способом. Для этого мы определяем общий тип Brand<T, S> следующим образом:
type Brand<T, S> = T & { __brand: S };
Brand — это пересечение базового типа T и фирменного типа { __brand: S }. Это делает полученный фирменный тип уникальным по сравнению с базовым типом. Кроме того, фирменные типы можно использовать везде, где ожидается объект типа T.
Теперь мы можем использовать Brand, чтобы включить определение строгих подтипов T. Также имейте в виду, что требуются некоторые дополнительные утверждения типа. Вот альтернативный фрагмент рабочего кода для варианта использования 1:
type Child<T> = Brand<T, 'Child'>; // represents any subtype of T
class FamilyTree<T> {
parent: T;
children: Child<T>[];
constructor(familyTree: FamilyTree<T>) {
this.parent = familyTree.parent;
this.children = familyTree.children;
}
}
// The definition of classes A-D remain the same
const a = new A(1);
const b = new B(2, 3) as Child<B>;
const c = new C(4, 5) as Child<C>;
const d = new D('Hey!') as Child<D>;
new FamilyTree<A>({ parent: a, children: [b, c] }); // ✅ No TS error
new FamilyTree<A>({ parent: a, children: [b, d] }); // ❌ TS error since d is not a subclass of A
new FamilyTree<A>({ parent: a, children: [b, a] }); // ❌ TS error since a is not an strict subclass of A
К сожалению, я не могу использовать эту технику для достижения строгого подтипирования во втором варианте использования, поскольку я не могу ожидать (и, во всяком случае, не хочу), чтобы разработчики Monguito вручную писали утверждения типа (т. е. as Child<X>) при указании своей модели предметной области. Итак, я понимаю, что ничего нельзя сделать для улучшения опыта разработчиков в этой области. Пожалуйста, докажите, что я ошибаюсь! 🤩
В заключение загляните в конец этой замечательной статьи , чтобы узнать больше о брендинге TS. Также вы можете посмотреть, как брендинг используется и в кодовой базе самого TS здесь.
«Более того, мне нужно обеспечить строгое подтипирование, а это означает, что экземпляры типа T не допускаются». Пожалуйста, отредактируйте, чтобы мотивировать это. Что на самом деле идет не так, когда вы разрешаете «экземпляры типа T», имея в виду, что структурное подтипирование означает, что каждый экземпляр типа
S extends Tтакже является экземпляромT? Сама природа этого, кажется, борется с системой типов, и вместо того, чтобы пытаться заставить TS подчиниться, я предпочитаю понять вариант использования. Без дополнительной информации это похоже на проблему XY.