Я пытаюсь добавить правильные типы параметров функции, где функция должна быть универсальной, а параметры должны относиться друг к другу.
Вот минимальный воспроизводимый пример (в реальном коде тип Foo
и объект objectOne
взяты из библиотеки, которую я не могу изменить, а функцию func
я пытаюсь создать сам):
interface Foo {
union: 'one' | 'two' | 'three';
object: {
One: {'oneRelatedKey': 'oneRelatedValue'};
Two: {'twoRelatedKey': 'twoRelatedValue'};
Three: {'threeRelatedKey': 'threeRelatedValue'};
};
};
const objectOne = {
one: {
func: (obj: Foo['object']['One']) => {console.info(obj)}
},
two: {
func: (obj: Foo['object']['Two']) => {console.info(obj)}
},
three: {
func: (obj: Foo['object']['Three']) => {console.info(obj)}
},
};
const func = <T extends Foo['union']>(one: T, two: Foo['object'][Capitalize<T>]) => {
objectOne[one].func(two);
}
Я получаю сообщение об ошибке two
:
Property ''twoRelatedKey'' is missing in type '{ oneRelatedKey: "oneRelatedValue"; }' but required in type '{ twoRelatedKey: "twoRelatedValue"; }'
Я хочу гарантировать, что func
вызывается со строкой из Foo['union']
и соответствующим объектом из Foo['object']
, индексированным по версии переданного аргумента Foo['union']
с заглавной буквы.
Тогда вызов func
должен работать следующим образом:
func('one', { 'oneRelatedKey': 'foo' })
Примечание. Следует изменить только типы параметров, а не тип objectOne
(объект взят из библиотеки, а MRE является упрощенной версией).
Я попробовал следующее, и это кажется ближе:
type FirstTestGeneric<T extends Foo['union']> = T;
type FirstTest = FirstTestGeneric<'one'>; // 'one'
type SecondTestGeneric<T extends Foo['union']> = Capitalize<T> extends keyof Foo['object'] ? Foo['object'][Capitalize<T>] : never;
type SecondTest = SecondTestGeneric<'one'>; // {'oneRelatedKey': string}
const testFunc = <T>(one: FirstTestGeneric<'one'>, two: SecondTestGeneric<'one'>) => {
objectOne[one].func(two);
}
Теперь мне просто нужно как-то изменить testFunc
, чтобы он использовал параметр типа. По какой-то причине это не работает:
const testFunc = <T extends Foo['union']>(one: FirstTestGeneric<T>, two: SecondTestGeneric<T>) => {
objectOne[one].func(two);
}
Я думаю, проблема в том, что тип T
принимает объединение. Если бы он принял только одну строку (из союза Foo['union']
), это, вероятно, сработало бы.
Я обнаружил следующее: Ограничить общий тип машинописного текста однострочным литеральным значением, запретив объединения
Пытаясь применить это здесь, мы можем сделать:
type GenericType<T extends Foo['union'] = Foo['union']> = { [U in T]: {
type: U;
parameter: Foo['object'][Capitalize<U>];
} }[T];
const testFuncOne = <T extends Foo['union']>(input: GenericType<T>) => {
objectOne[input.type].func(input.parameter);
}
Мы все еще получаем эту ошибку func(input.parameter)
:
Свойство «twoRelatedKey» отсутствует в типе «{ oneRelatedKey: нить; }», но требуется в типе «{ twoRelatedKey: string; }'
По какой-то причине func
ожидает аргумент следующего типа:
(property) func: (obj: {
oneRelatedKey: string;
} & {
twoRelatedKey: string;
} & {
threeRelatedKey: string;
}) => void
У меня есть смутное подозрение, что это как-то связано с контрвариантностью функций по своим параметрам.
@JaredSmith Примечание. Примером является MRE. В реальном коде тип и объект взяты из библиотеки, которую я не могу изменить, а функцию func
я пытаюсь создать сам.
Вызов func
действительно работает, например. func("one", { oneRelatedKey: "oneRelatedValue" })
- эти типы работают на улице func
. Но внутри func
, T
еще не указано; это "one" | "two" | "three"
, поэтому objectOne[one]
может быть любым из этих методов, а two
может быть любым из этих объектов, и не все они взаимно совместимы.
@jonrsharpe Точно, поэтому мне нужно сузить это с помощью условного оператора. Смотрите мое редактирование.
Это возможно с помощью небольшой манипуляции с типами:
interface Foo {
union: 'one' | 'two' | 'three';
object: {
One: {'oneRelatedKey': 'oneRelatedValue'};
Two: {'twoRelatedKey': 'twoRelatedValue'};
Three: {'threeRelatedKey': 'threeRelatedValue'};
};
}
type OneObjType = {
[K in Foo['union']]: { func: (x: Foo['object'][Capitalize<K>]) => void }
}
const objectOne = {
one: {
func: (obj: Foo['object']['One']) => {console.info(obj)}
},
two: {
func: (obj: Foo['object']['Two']) => {console.info(obj)}
},
three: {
func: (obj: Foo['object']['Three']) => {console.info(obj)}
},
};
const retyped: OneObjType = objectOne; // NOTE: type-safe, no cast
const func = <T extends Foo['union']>(one: T, two: Foo['object'][Capitalize<T>]) => {
retyped[one].func(two);
}
Обратите внимание, что подпись вашей исходной функции была правильной, и я ее не менял. Хитрость заключается в том, чтобы заставить компилятор принять, что параметры методов в objectOne
сопоставляются обратно с объединением и что объединение соединяется с соответствующими объектами в интерфейсе Foo
с помощью псевдонима retyped
. Также обратите внимание, что это переосмысление типа, а не приведение: компилятор по-прежнему обеспечивает безопасность.
Причина, по которой это работает, но ваш исходный код не имеет отношения к типу objectOne
, который вы индексируете и вызываете метод в теле func
. Компилятор не понимает, что ключи в objectOne
связаны с Foo['union']
и что параметры методов func
этого объекта связаны со значениями Foo['object']
. Вся информация для определения этого доступна, мы, люди, можем ее видеть, но компилятор должен определить наиболее общий тип, который он может (и в 99% случаев это именно то поведение, которое вам нужно), поэтому он не может просто предполагать то, что вы хотите. хочу, чтобы это было здесь.
Есть несколько способов переопределить это поведение: один из них — использовать квалификатор as const
, чтобы компилятор вывел самый узкий тип, который он может, а не самый широкий. Но для этого потребуется изменить аннотацию фактического значения objectOne
, чего вы не можете сделать, поскольку получаете его из сторонней библиотеки (это также не будет работать, если objectOne
является изменяемым, а не действительно константным, as const
создаст свойства readonly
).
Другой способ — соединить точки для компилятора, присвоив существующее значение совместимому, но более узкому типу (чем тот, который у него уже есть). Это подход, который я использовал здесь: я извлекаю сопоставленный тип из Foo
, называемый OneObjType
это поясняет, как ключи и параметры метода func
сопоставляются с Foo
.
В качестве упрощенного примера «назначения псевдонима более узкого типа» рассмотрим следующее:
const x: 'a' | 'b' = 'a';
const y: 'a' = x; // legal narrowing assignment
function foo(a: 'a') {}
foo(x); // ERROR: cannot assign 'a' | 'b' to 'a'
foo(y); // Fine, no error!
x === y; // true
Несмотря на то, что 'a'
является более узким типом, чем 'a' | 'b'
, присвоение y
является законным, поскольку значение уровня термина представляет собой const
, и компилятор может доказать, что это безопасно. Затем вы можете использовать y
там, где требуется литеральный тип 'a'
, но вы не можете использовать x
, потому что вы не можете использовать 'a' | 'b'
для удовлетворения типа, требующего 'a'
. Это правда, хотя y
— это всего лишь псевдоним x
. Они указывают на одно и то же значение уровня термина (строковый литерал 'a'
), но x
и y
имеют разные типы, и с одним можно делать то, чего нельзя с другим (например, переходить к foo
).
Выполнение присваивания const retyped: OneObjType = objectOne;
вообще не меняет objectOne
, оно просто создает для него новое имя, имеющее другой, но совместимый тип (именно поэтому компилятор разрешает присваивание), и мы можем безопасно вызывать связанные методы, используя это другое представление объекта: retyped[one].func(two);
работает, потому что retyped
— это всего лишь псевдоним для objectOne
, но он имеет другой тип, который, как понимает компилятор, отображает обратно в интерфейс Foo
. Это просто способ сказать компилятору, что он должен рассматривать objectOne
как более узкий тип OneObjType
, а не как тип, который он уже имеет, когда мы ссылаемся на него через псевдоним retyped
.
Это не сильно отличается от использования сужения типа с помощью условных проверок, за исключением того, что это происходит исключительно во время компиляции, нет кода времени выполнения, который нужно выполнять для сужения типа, что делает решение более элегантным.
Обратите внимание, что все это работает, даже если вы получаете Foo
и objectOne
из библиотеки, потому что мы вообще не изменяем ни один из них, мы просто описываем отношение, которое они имеют друг к другу, чтобы компилятор мог это понять.
Спасибо, Джаред, но я бы хотел избежать изменения подписи objectOne/использовать ту же функцию. MRE — это сильно упрощенная версия, на самом деле этот тип намного сложнее. Смотрите мое РЕДАКТИРОВАНИЕ в ОП. Я думаю, это можно сделать с помощью условных типов. Кажется, мне не хватает только того, как ограничить параметр типа одной строкой из объединения вместо T extends 'one' | 'two' | 'three'
, где T может быть one
, но также и 'one' | 'two'
.
@jcalz написал ответ, который кажется похожим: stackoverflow.com/questions/72913279/…. Пока не знаю, как это применить.
«Я бы хотел избежать изменения подписи objectOne/использовать ту же функцию». Я ничего не менял, я буквально скопировал/вставил ваш код. Все, что я делаю, это сужу тип objectOne
, переназначая его на более узкий, но все же совместимый тип, во многом так же, как это делает оператор satisfies
(или условные типы, если уж на то пошло). «MRE — это сильно упрощенная версия, на самом деле этот тип намного сложнее», тогда это не MRE: я ответил на фактический вопрос, который вы задали. Если это не решит вашу проблему, ничего страшного, по ходу дела мы часто обнаруживаем новые ограничения.
Но правильным этикетом было бы задать следующий вопрос с соответствующими ограничениями, а не аннулировать существующий ответ. Наверное, я до сих пор не понимаю, как это не решает проблему: тому отображаемому типу, который я написал, все равно, есть ли 3 члена союза или 100... но это, вероятно, разговор для продолжения.
Вопрос остается тот же, с уточнением, что менять следует только типы параметров func
, а не тип objectOne
.
@Магнус, почему? На самом деле я не меняю тип. Вы можете переместить всю ту же логику внутрь func
, и она все равно будет работать. Вам не нужно заходить в папку node_modules и менять тип или что-то безумное...
Я вижу, что вы сделали, и могу попытаться применить это к своему коду. Однако можете ли вы добавить альтернативу, в которой изменяются только типы параметров? В моем случае это будет не только проще, но и условные типы являются наиболее распространенным способом связывания параметров друг с другом (например: artsy.github.io/blog/2018/11/21/conditional-types-in -typescript)
Я поигрался с вашим ответом, но не понимаю, как и почему он работает. По какой-то причине func изменил то, что он ожидает получить в вашей версии по сравнению с OP.
@Magnus «Я... не понимаю, как и почему это работает» оставил редактирование ответа, которое объясняет небольшое, но существенное отличие от вашего исходного кода.
Спасибо. У вас случайно нет ссылки на руководство (официальную документацию), в котором подробно описывается это или подобное поведение? Должен ли я предположить, что компилятору невозможно понять эту связь, работая только с типами параметров? Отмечено ли это где-нибудь в документации (во всех руководствах, которые я читал, говорится о дистрибутивных условных типах для установления такой связи)?
«Должен ли я предположить, что невозможно заставить компилятор понять связь, работая только с типами параметров?» правильно, потому что проблема в том, что вы вводите параметры с типами из Foo
, но используете их для индексации в objectOne
, который не привязан к Foo
так, как вам нужно. Вы могли бы решить эту проблему, извлекая типы параметров из typeof objectOne
, но они не будут связаны с Foo
(по крайней мере, с точки зрения компилятора). Что касается документации IDK, обратите внимание на следующее: const x: 'a' | 'b' = 'a'; const y: 'a' = x;
Несмотря на то, что 'a'
является более узким типом, чем 'a' | 'b'
, присвоение y
допустимо, поскольку значение уровня термина является константой, и компилятор может доказать, что это безопасно. Затем вы можете использовать y
там, где требуется литеральный тип 'a'
, но вы не можете использовать x
, потому что вы не можете использовать 'a' | 'b'
для удовлетворения типа, требующего 'a'
. Это правда, хотя y
— это всего лишь псевдоним x
. Они указывают на одно и то же значение уровня термина (строковый литерал 'a'
), но x и y имеют разные типы, и с одним можно делать то, чего нельзя с другим. Мой ответ — та же самая идея, адаптированная к вашему коду.
Также забавно, что вы упомянули дистрибутивные условные типы, потому что это первый подход, который я пытался решить. Но это не удалось, потому что на самом деле это не решает проблему: оно просто распределяет что-то по Foo['union']
, но проблема в том, что распределенные типы по-прежнему не проясняют связь с objectOne
. Как только вы проясните эту связь для компилятора, проблема исчезнет, и распределенные условные типы на самом деле не нужны (в данном конкретном случае это просто излишняя сложность). Извините за стену текста, надеюсь, это прояснило ситуацию.
Это невероятно полезно и именно та глубина, которая мне нужна, чтобы лучше понять проблему/решение. Еще раз спасибо! Я не сомневаюсь, что это будет полезно и другим. Кое-что из этого действительно следует добавить в документацию.
Джаред, я посмотрел на твой пример a
/b
и думаю, что заметил что-то не так (если только я тебя не понял неправильно). Единственная причина, по которой вы можете присвоить x
y
, заключается в том, что вы уже сузили x
до буквального типа a
(который того же типа, что и y
). Если бы x
на самом деле был более широким типом, чем y
, присвоение никогда не было бы разрешено: const x: 'a' | 'b' = Math.random() > 0.5 ? 'a' : 'b'; const y: 'a' = x; // error
. Не имеет значения, является ли это объявлением const
или нет, т. е. изменение на let
не влияет на возможность назначения x
на y
.
Посмотрите игровую площадку здесь (наведите курсор мыши на x
, затем на m
, m
соответствует x
в вашем примере и имеет тип a
после первого задания): typescriptlang.org/play?#code/…
Не DV, но для меня это не имеет смысла: если вы просто сделаете Foo типом объединения
{ name: string; object: { "key": "value" }
для различных комбинаций, проблема исчезнет. Если это удовлетворительно, я напишу это как ответ, иначе чего мне здесь не хватает?