У меня есть набор файлов JSON, в которых хранятся исходные данные. Для языка существует один файл, и все они имеют одинаковые ключи, но разные типы значений. Это вот так:
// source-en.json
{
"action": ["change", "check"],
"device": ["coil", "relay"],
}
// source-pl.json
{
"action": ["wymienić", "sprawdzić"],
"device": [
{
"gender": "f",
"singular": "cewkę",
"plural": "cewki"
},
{
"gender": "m",
"singular": "przekaźnik",
"plural": "przekaźniki"
}
]
}
Итак, я мог бы описать их с помощью интерфейсов:
interface SourceEn {
action: string[],
device: string[],
}
interface SourcePl {
action: string[],
device: NounForms[],
}
interface NounForms {
gender: 'm' | 'f' | 'n' | 'pl';
singular: string;
plural: string;
}
Теперь у меня есть набор классов, которые потребляют данные: EnConsumer, PlConsumer... которые наследуются от BaseConsumer. BaseConsumer знает, как получить исходные файлы и превратить JSON в реальные данные, в то время как конкретные классы знают, как справиться с языковыми различиями. Итак, BaseConsumer выглядит так:
type Source = SourceEn | SourcePl;
class BaseConsumer<T extends Source> {
public readonly action: Collection<ArrayItem<T['action']>>;
public readonly device: Collection<ArrayItem<T['device']>>;
public constructor(language: Language) {
const data = this.getData(language);
this.action = new Collection(data.action);
this.device = new Collection(data.device);
}
protected getData(language: Language): T {...}
}
class Collection<T> {
public constructor(private store: T[]) {}
}
type ArrayItem<A extends unknown[]> = A extends (infer E)[] ? E : never;
enum Language {
EN = 'en',
PL = 'pl',
}
Но это не работает. В частности, строка this.device = new Collection(data.device); выдает две ошибки:
data.device:Аргумент типа
string[] | NounForms[]не может быть назначен параметру типаstring[]
ТипNounForms[]не может быть присвоен типуstring[]
ТипNounFormsне может быть присвоен типуstring
this.device:Тип
Collection<string>не может быть присвоен типуCollection<Source['device']>
Типstringне может быть присвоен типуSource['device']
Проблемы возникают из-за того, что T extends Source представляет собой союз SourceEn и SourcePl. Но при использовании class ConsumerPl extends BaseConsumer<SourcePl> класс ConsumerPl должен знать, какой из интерфейсов действительно применяется и какие типы следует применять к соответствующим полям.
Я не знаю, как выразить это на TypeScript. Я не хочу быть слишком откровенным, потому что сегодня он поддерживает английский и польский языки, но завтра это могут быть также клингонский и квенья. Таким образом, подробные условные типы не являются допустимым решением. Итак, как мне сделать BaseConsumer правильно общим?
@jonrsharpe: да, у меня есть определенные файлы JSON, которые я хочу ввести. У них один и тот же набор ключей, которые представляют различные части предложения, и Потребители используют эти фиксированные части для фактического составления этого предложения. Конечно, в разных языках разная грамматика, поэтому типы данных под ключами различаются.
@jcalz: Я добавил и сюда, и на игровую площадку: tsplay.dev/mb2B8m . Как вы можете видеть, я Collection на самом деле принимаю не-массив, а в BaseConsumer я использую ArrayType, чтобы отменить массив типа из ключа Source.
В вашем методе getData вы действительно не уверены, какой тип возвращаете? Например, SourceEn или SourcePl?
Я предлагаю изменить ваш общий язык, чтобы он сопоставлялся с названием языка и базовым типом исходных данных (то есть string, а не string[]), как показано в этой ссылке на игровую площадку. Это полностью решает вопрос? Если да, я напишу ответ с объяснением; если нет, то что мне не хватает?
@jcalz: да, извини, я виноват. Исправил это и здесь, и в tsplay.dev/N97PoW . ArrayItem используется для получения типа массива (string[] => string). Тем не менее, я видел ваше последнее предложение и еще не разобрал его. Например, я не знаю, почему в number есть { [K in keyof T]: T[K][number] }? Но я проанализирую это позже сегодня. Спасибо за обстоятельный ответ :)
@Эндрю: Я знаю, что возвращается, потому что оно выбрано на основе параметра language. Проблема в том, что у меня проблемы с выражением этого в TypeScript. Использование T extends Source означает, что я ссылаюсь на объединение, поэтому каждый ключ имеет тип объединения типов всех членов объединения. @jcalz уже предоставил многообещающее предложение, но мне еще предстоит его проанализировать, и мне уже интересно, можно ли это сделать проще :)
Но знаете ли вы заранее, будет ли это NounForms объект или SourceEn объект заранее? В любом случае, похоже, что где-то в ваших типах есть дополнительный массив. Посмотрите ответ, который я добавил ниже. Я отредактирую его, включив в него игровую площадку ts. Ошибок нет, но мне пришлось сопоставить глубину массива, и я не уверен, какой из них правильный... либо сделать тип массивом массивов, либо создать тип конструктора без массива.
"Мне уже интересно, можно ли это сделать проще" ?? Учитывая типы, которые вы дали, я бы сказал «нет», и что использовать ArrayItem менее «просто», чем использовать number (Array<T>[number] — это T, что я бы с радостью объяснил, если в конечном итоге напишу ответный пост.) Если я разрешено изменять базовые типы, чтобы нам не нужно было анализировать массивы, тогда я бы написал это как вот так, и вы всегда могли сопоставить версию с массивами точно так же, как я сопоставил версию с Collection. Дайте мне знать, когда вы закончите анализировать мое предложение и как вы хотите действовать.
@jcalz: К сожалению, типы Source* привязаны к структуре данных и не могут быть изменены. Код из вашего первого комментария, конечно, работает. Однако я не совсем понимаю, как это сделать. Во-первых, в type ToBaseSource<T extends Record<keyof T, any[]>> я не понимаю, как это возможно, что T расширяется в keyof T. Во-вторых, я думаю, что {[K in keyof T]: T[K][number]} означает «объект с ключами из T и значениями типа ключа T. Но почему [number], если и SourcePl, и SourceEn имеют значения ключей типов массива? Пожалуйста, дайте ответ, чтобы объяснить.






Это работает, но сложно точно сказать, что вы ищете, не видя вашего getData метода.
interface SourceEn {
action: string[],
device: string[],
}
interface SourcePl {
action: string[],
device: NounForms[],
}
interface NounForms {
gender: 'm' | 'f' | 'n' | 'pl';
singular: string;
plural: string;
}
type Source = SourceEn | SourcePl;
class Collection<T> {
public constructor(private store: T) {} // Add the array back in here if that's your intention, but then use the commented out return value below.
}
type ArrayItem<A extends unknown[]> = A extends (infer E)[] ? E : never;
enum Language {
EN = 'en',
PL = 'pl',
}
class BaseConsumer<T extends Source> {
public readonly action: Collection<T['action']>;
public readonly device: Collection<T['device']>;
public constructor(language: Language) {
const data: T = this.getData(language)
// this.action = new Collection<typeof data.action>([data.action]); Either this one if you want an array of arrays.
this.action = new Collection<typeof data.action>(data.action); // Or this one, but then I removed the array from your Collection class.
this.device = new Collection<typeof data.device>(data.device);
}
protected getData(language: Language): T { return null! as T }
}
Если вам нужен массив массивов, используйте закомментированное возвращаемое значение, возможно, с методом, который проверяет, является ли это массивом нужной глубины, чтобы всегда возвращать его в нужной форме, в противном случае удалите массив из Collection класс.
Извините, не могу привести в порядок. В любом случае, в итоге я набираю new BaseConsumer<SourceEn>(Language.EN).device как Collection<string[]>, хотя так и должно быть Collection<string>. Это важно, поскольку Collection имеет методы для получения одного из элементов store: T[], которые возвращают T. Если бы у меня был store: T, мне пришлось бы разобрать массив под T. Даже ввод Collection с T и store: T неверен, поскольку он допускает типы, не являющиеся массивами. Collection<T extends any[]> было бы лучше, но зачем, если мы можем выразить это проще, просто разрешив любой тип с помощью Collection<T> and store: T[]`
Минимальные изменения для устранения ошибок следующие:
type ArrayItem<A extends unknown[]> = A[number];
class BaseConsumer<T extends Source> {
public readonly action: Collection<ArrayItem<T['action']>>;
public readonly device: Collection<ArrayItem<T['device']>>;
public constructor(language: Language) {
const data = this.getData(language);
this.action = new Collection<ArrayItem<T['action']>>(data.action);
this.device = new Collection<ArrayItem<T['device']>>(data.device);
}
protected getData(language: Language): T { return null! }
}
Я изменил ArrayItem ваш условный тип на более простой индексированный тип доступа . Массив типа Array<X> имеет числовую сигнатуру индекса типа {[k: number]: X}, поскольку TypeScript моделирует массивы как имеющие элементы с числовыми ключами. Итак, если у вас есть string[] и вы вносите в него индекс с помощью числового ключа, вы получите string. Ваш условный тип концептуально один и тот же, но компилятору гораздо сложнее рассуждать об универсальных условных типах, чем об универсальных типах индексированного доступа.
Я также вручную указал аргумент универсального типа для Collection, чтобы он был соответствующим универсальным типом. Если вы этого не сделаете, компилятор попытается вывести этот тип, и в конечном итоге он расширится data от T до ограничения Source, а объединение больше не будет соответствовать типу this.action и this.device. Расширение универсальных значений TypeScript до их ограничений — распространенная ошибка, и один из способов избежать этого — вручную указывать аргументы универсального типа для функций.
Это все хорошо, но вы не получите от BaseConsumer общего поведения, которое вам нужно. Поскольку аргумент конструктора не является общим Language, вы получаете бесполезный BaseConsumer<Source>:
const pl = new BaseConsumer(Language.PL)
//const pl: BaseConsumer<Source>
pl.device
// ^? (property) BaseConsumer<Source>.device: Collection<string | NounForms>
Это не то, чего вы хотите. Тип pl.device должен быть Collection<NounForms>, верно? Поэтому нам нужен другой подход.
Чтобы это работало, мы должны сделать BaseConsumer<L>общим в типе L аргумента language constructor, а затем использовать тип сопоставления для соединения этих языковых имен с соответствующими типами данных action и device без каких-либо массивов для получения в пути. То есть мы могли бы использовать отображение типа
interface LanguageSourceMap {
en: {
action: string,
device: string,
},
pl: {
action: string,
device: NounForms,
}
}
а затем, например, LanguageSourceMap[L]['action'] — это тип данных action для языка L (с использованием индексированных типов доступа для детализации ключа и подраздела LanguageSourceMap).
Как только мы это получим, мы можем написать BaseConsumer как:
class BaseConsumer<L extends Language> {
public readonly action: Collection<LanguageSourceMap[L]['action']>;
public readonly device: Collection<LanguageSourceMap[L]['device']>;
public constructor(language: L) {
const data = this.getData(language);
this.action = new Collection(data.action);
this.device = new Collection(data.device);
}
protected getData(language: Language):
{ [K in keyof LanguageSourceMap[L]]:
LanguageSourceMap[L][K][] } { return null! }
}
что работает хорошо, потому что теперь getData() возвращает сопоставленный тип поверх LanguageSourceMap[L], и индексация в него с помощью action или device дает именно тот индексированный тип доступа, который у нас есть для this.action и this.device. Компилятору гораздо легче делать выводы о LanguageSourceMap[L][K][], где L extends Language и K extends keyof LanguageSourceMap[K], чем о T extends Source. Компилятору проще анализировать отображаемые типы и индексированные типы доступа, чем объединения и условные типы. Основу этого подхода см. в microsoft/TypeScript#47109, где мы начинаем с базового типа сопоставления, такого как LanguageSourceMap, и выражаем вещи в терминах универсальных индексов в этот тип и в отображаемые типы поверх этого типа.
Давайте убедимся, что new BaseConsumer ведет себя так, как мы хотим:
const pl = new BaseConsumer(Language.PL)
// const pl: BaseConsumer<Language.PL>
pl.device
// ^? (property) BaseConsumer<Language.PL>.device: Collection<NounForms>
Выглядит неплохо.
Мы почти закончили. Единственное, мы не хотим выписывать LanguageSourceMap вручную, когда у вас уже есть типы SourceEn и SourcePl. Итак, давайте перепишем LanguageSourceMap так, чтобы он явно их использовал:
type ToBaseSource<T extends Record<keyof T, any[]>> =
{ [K in keyof T]: T[K][number] };
interface LanguageSourceMap {
[Language.EN]: ToBaseSource<SourceEn>;
[Language.PL]: ToBaseSource<SourcePl>;
}
Здесь ToBaseSource<T> принимает любые T, все свойства которых являются массивами (он использует рекурсивное ограничение T extends Record<keyof T, any[]>. Ограничения типа X extends Record<keyof X, Y> полезны для ограничения типа значения свойств, не заботясь о типе ключа), и для каждого свойства массива T[K] используется индексированный number доступ для получения типа элемента массива. Это то же самое, что и упрощенное ArrayItem, сопоставленное со свойствами t.
И вот мы закончили. LanguageSourceMap здесь эквивалентен руководству, написанному выше.
Обратите внимание, что все это было бы проще, если бы вы начали с LanguageSourceMap и вычислили SourceEn и SourePl с его помощью. Но это не так, и, очевидно, вы не можете контролировать эти типы. Так что это лучшее, что я могу предложить.
Детская площадка, ссылка на код
Большое спасибо. После того, как вы указали на это, становится очевидным, что массив можно выразить как объект с числовыми ключами - в конце концов, typeof new Array() === 'object'. Я тоже не знал о рекурсивных ограничениях, так что еще раз спасибо. В конце концов, мне удалось это немного упростить. 1: ключи Source* одинаковы, поэтому я составил для них перечисление, которое заменило keyof T в ToBaseSource. 2: Я отказался от LanguageSourceMap[L] в пользу ToBaseSource<SourcePl> — думаю, это уменьшит количество вещей, которые нужно помнить при добавлении нового языка. Посмотреть изменения: tsplay.dev/m3dxyW
Есть ли причина, по которой вы не начинаете просто с
{ [key: string]: (string | NounForms)[] }?