Ограничить параметр как некое конечное объединение литеральных значений, когда используются подмешивания

Мне нужен способ ограничить параметр универсальной функции объединением буквальных значений, то есть

  • 'foo', 'foo' | 'bar', 42 и т. Д. Должны быть разрешены
  • string или number не должны
  • string & SomethingElse или number & AnotherThing также не должны
  • ('foo' | 'bar') & SomethingElse, 42 & AnotherThing следует разрешить (менее важно)

Рассмотрим такую ​​функцию

declare function test<K extends string>(t: { [P in K]: string; }): void;

Идея состоит в том, чтобы каждое возможное значение K имело соответствующее свойство в t, чтобы мы могли искать значения в t на основе некоторых K-типизированных значений.

Некоторые примеры использования, показывающие, что все идет хорошо:

declare const foobar: { foo: 'bar'; };
test(foobar); // compiles, correct
test<'foo'>(foobar); // compiles, correct
test<'bar'>(foobar); // compiler error, correct
//          ^^^^^^
// Argument of type '{ foo: "bar"; }' is not assignable to parameter of type '{ bar: string; }'.
//  Property 'bar' is missing in type '{ foo: "bar"; }' but required in type '{ bar: string; }'.

Однако есть проблема:

test<string>(foobar); // compiles, INCORRECT

В случае test<string> { [P in K]: string; } становится { [P: string]: string; }, что не гарантирует, что на самом деле существует ключ для каждого значения (и действительно, это было бы невозможно, если бы не были некоторые махинации с прокси-геттером). Поэтому я не хочу разрешать { [P: string]: string; } аргументов в моей функции test, что эквивалентно тому, что я не хочу, чтобы моя функция test допускала string в качестве значения для K.

Теперь можно обойти это… своего рода. Учитывать

declare function test<K extends string>(t: string extends K ? never : { [P in K]: string; }): void;

Тест string extends K сообщает нам, был ли string передан в K, и в таком случае мы делаем аргумент never, предотвращая передачу каких-либо реальных значений функции (здесь лучше было бы вызвать ошибку, но Typescript не поддерживает тот). И так

test<string>(foobar); // compiler error, correct
//           ^^^^^^
// Argument of type '{ foo: "bar"; }' is not assignable to parameter of type 'never'.

Однако это не решает проблему, когда мы используем строковые микшеры, как это делает наш проект. Учитывать

type StringExtended = string & { extension(): void; };
test<StringExtended>(foobar); // compiles, INCORRECT

Поскольку string extends StringExtended ложно, наш never хак не применяется, и мы возвращаемся к { [P in K]: string; }, который, как ни странно, теперь обозначается как {}, а не как { [P: string]: string; }. В любом случае, он все равно компилируется. Я не хочу этого, потому что в нашем проекте используется много таких расширений (в частности, с использованием фирменные примитивы), и это слишком правдоподобно для некоторого K extends string ограничения, которое было предназначено для строковых литералов, чтобы вместо этого принимать такие расширения строк, нарушая тип - безопасность функции.

Для более «реалистичного» примера рассмотрим

interface Keyed<K extends string, D> { key: K; data: D; }

function toNameOf<K extends string>(
    { key }: Keyed<K, any>,
    names: string extends K ? never : { [P in K]: string; },
): string {
    return names[key];
}

declare const k1: Keyed<'foo' | 'bar', number>;
toNameOf(k1, { foo: 'Foo', bar: 'Bar' });
toNameOf(k1, {});
//           ^^
toNameOf(k1, { foo: 'Foo' });
//           ^^^^^^^^^^^^^^
toNameOf(k1, { bar: 'Bar' });
//           ^^^^^^^^^^^^^^

declare const k2: Keyed<string, number>;
toNameOf(k2, { foo: 'Foo', bar: 'Bar' });
//           ^^^^^^^^^^^^^^^^^^^^^^^^^^
toNameOf(k2, {});
//           ^^
toNameOf(k2, { foo: 'Foo' });
//           ^^^^^^^^^^^^^^
toNameOf(k2, { bar: 'Bar' });
//           ^^^^^^^^^^^^^^

declare const k3: Keyed<StringExtended, number>;
toNameOf(k3, { foo: 'Foo', bar: 'Bar' });
toNameOf(k3, {});
toNameOf(k3, { foo: 'Foo' });
toNameOf(k3, { bar: 'Bar' });

k1 случаи правильно терпят неудачу, если мы не получаем names аргумент, который фактически охватывает все возможности. А в k2 мы просто все отвергаем; k2 не поддерживает тип, который toNamesOf поддерживает. Но в k3 случаях все компилируются без ошибок или предупреждений, несмотря на все мои усилия. И структуры, аналогичные Keyed, которые заполнены (правильно, в других случаях использования) StringExtended, а не каким-либо конкретным литералом, находятся повсюду в нашем коде.


person KRyan    schedule 21.07.2020    source источник


Ответы (1)


Я нашел решение, которое считаю достаточно надежным. Он не касается четвертого пункта, но, как уже отмечалось, для меня это было менее важно.

type LikeLiteralUnion<K extends string | number | symbol> =
    string extends K ? never :
    number extends K ? never :
    symbol extends K ? never :
    [{}] extends [{ [P in K]: unknown; }] ? never :
    K;

Я отметил в вопросе, что когда вы использовали миксин, например type K = string & { extension(): void; }, { [P in K]: unknown; } выводится как {}, а не как { [P: string]: unknown; }. Оказывается, это различие между ними, которое мы можем использовать.

Использовать как

declare function takeLiteralUnion<K extends string | number | symbol>(k: LikeLiteralUnion<K>): void;
person KRyan    schedule 23.07.2020