Как получить «необязательное» свойство от union? - PullRequest
1 голос
/ 29 апреля 2019

Это то, чего я хочу достичь:

interface Point2d {
  x: number;
  y: number;
}

interface Point3d {
  x: number;
  y: number;
  z: number;
}

type Point = Point2d | Point3d;

const p: Point = getPoint();
const z: number | undefined = p.z;

Однако это невозможно: TypeScript выдаст ошибку в последней строке, которая z не определена в Point2d.Есть ли способ заставить эту работу?

Ответы [ 2 ]

3 голосов
/ 29 апреля 2019

Вы можете использовать охрану типа для проверки наличия z, например

const p: Point = getPoint();
const z: number | undefined = ('z' in p) ? p.z : undefined; 

Пример этого можно посмотреть здесь .

Или вы можетесделать это более универсальным с помощью функции, такой как:

const get = (point: Point, prop: string): number => prop in point ? point[prop] : undefined

Ключевое слово in на самом деле является оператором javascript, но его часто используют в сценариях, подобных этому, в машинописном тексте (например, см. хороший пример здесь )

2 голосов
/ 30 апреля 2019

Эта проблема (и особенно обсуждение) привлекла мое внимание, и это то, что мне удалось сделать.

Прежде всего, мы можем попробовать и использовать UnionToIntersection тип , чтобы получить тип, включающий все свойства из всех элементов объединения. Первая попытка была такой:

type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
type Intersect = UnionToIntersection<Point>;
type Keys = keyof Intersect;
function check<K extends Keys>(p: Point, key: K): p is Point & { [key in K]: Point[key] }  { 
    return key in p;
 };
function get<K extends Keys>(p: Point, key: K) {
    return check(p, key) ? p[key] : undefined;
};

Идея заключается в следующем: если свойство есть - мы ограничим тип, требуя, чтобы оно действительно было здесь, в дополнение к тому, что мы знали раньше; а затем просто вернуть этот тип. Этот код, однако, не компилируется: Type 'key' cannot be used to index type 'Point'. Это несколько ожидаемо, поскольку мы не можем гарантировать, что тип индексации является правильным для любого произвольного Point (в конце концов, это проблема, которую мы пытаемся решить с помощью старт).

Исправление довольно простое:

function check<K extends Keys>(p: Point, key: K): p is Point & { [key in K]: Intersect[key] } { 
    return key in p;
 };
function get<K extends Keys>(p: Point, key: K) {
    return check(p, key) ? p[key] : undefined;
};
const z = get(p, 'z');
if (z) {
    console.log(z.toFixed()); // this typechecks - 'z' is of type 'number'
}

Однако это не конец. Представьте, что по какой-то причине нам нужно хранить координату Point3d x в виде строки, а не числа. Приведенный выше код в этом случае потерпит неудачу (я вставлю здесь весь блок, поэтому мы не будем смешивать его с соответствующим кодом):

interface Point2d {
    x: number;
    y: number;
}

interface Point3d {
    x: string;
    y: number;
    z: number;
}

type Point = Point2d | Point3d;

function getPoint(): Point { throw ("unimplemented"); };
const p: Point = getPoint();

type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
type Intersect = UnionToIntersection<Point>;
type Keys = keyof Intersect;

function check<K extends Keys>(p: Point, key: K): p is Point & { [key in K]: Intersect[key] } { 
    return key in p;
 };
function get<K extends Keys>(p: Point, key: K) {
    return check(p, key) ? p[key] : undefined;
};

const x = get(p, 'x');
if (x && typeof x === 'number') {
    console.log(x.toFixed()); // error: property 'toFixed' does not exist on type 'never'
}

Проблема в том, что на пересечении свойство x должно быть числом и строкой одновременно, и это невозможно, поэтому оно выводится на never.

Кажется, мы должны думать о другом. Давайте попробуем описать, что мы хотим:

  • для каждого Key, существующего в некоторой части объединения,
  • и за каждый Part объединения
  • мы хотим иметь тип Part[Key], если он существует, или undefined в противном случае,
  • и мы хотим отобразить Key на объединение этих типов для всех Part с.

Хорошо, давайте начнем с буквального перевода этого в систему типов:

type ValueOfUnion<T, K> = T extends any ? K extends keyof T ? T[K] : undefined : never;

Это довольно просто:

  • для любого типа в объединении T (здесь мы используем распределение объединения),
  • если в нем есть ключ K, мы возвращаем T[K],
  • иначе мы вернемся undefined,
  • и предложение lase является просто синтаксической частью, которая никогда не выполняется (поскольку все типы расширяются any).

Теперь давайте продолжим, создав отображение:

type UnionMapping<T> = {
    [K in keyof UnionToIntersection<T>]: ValueOfUnion<T, K>;
}
type MappedPoint = UnionMapping<Point>;

Опять же, это ясно: для любого ключа K, существующего на пересечении (мы уже знаем, как его создать), мы получаем соответствующие значения.

И, наконец, геттер, который становится смехотворно простым:

function get<K extends keyof MappedPoint>(p: Point, key: K) { 
    return (p as MappedPoint)[key]
};

Типовое утверждение здесь является правильным, поскольку каждый конкретный Point является MappedPoint. Обратите внимание, что мы не можем просто требовать, чтобы функция get получала MappedPoint, так как TypeScript будет раздражен:

Argument of type 'Point' is not assignable to parameter of type 'UnionMapping<Point>'.
  Property 'z' is missing in type 'Point2d' but required in type 'UnionMapping<Point>'.

Проблема в том, что отображение теряет возможность, добавленную объединением (заменяя его на возможный вывод undefined).

Краткое тестирование показывает, что это действительно работает:

const x = get(p, 'x'); // x is number | string - note that we got rid of unnecessary "undefined"
if (typeof x === 'number') {
    console.log(x.toFixed());
} else {
    console.log(x.toLowerCase());
}

const z = get(p, 'z'); // z is number | undefined, since it doesn't exist on some part of union
if (z) {
    console.log('Point 3d with z = ' + z.toFixed())
} else {
    console.log('Point2d')
}

Надеюсь, это поможет!


Обновление : комментарий автора темы предложил следующую конструкцию для дальнейшей эргономики:

type UnionMapping<T> = {
    [K in keyof UnionToIntersection<T> | keyof T]: ValueOfUnion<T, K>;
}
type UnionMerge<T> = Pick<UnionMapping<T>, keyof T> & Partial<UnionMapping<T>>;

function get<K extends keyof UnionMerge<Point>>(p: UnionMerge<Point>, key: K) { 
    return p[key];
};

Идея состоит в том, чтобы избавиться от ненужных утверждений типа, сделав свойства действительно необязательными (а не просто пропуская undefined).

Кроме того, используя идею каррирования, мы можем обобщить эту конструкцию сразу для нескольких типов объединений. Трудно начать работать в текущей формулировке, так как мы не можем явно передать один тип и позволить TypeScript вывести другой, а это объявление функции неправильно выводит тип T:

function get<T, K extends keyof UnionMerge<Point>>(p: UnionMerge<T>, key: K) { 
    return p[key];
};
get(p, 'z'); // error, since T is inferred as {x: {}, y: {}}

Но, разделив функцию на две части, мы можем явно указать тип объединения:

function getter<T>(p: UnionMerge<T>) {
    return <K extends keyof UnionMerge<T>>(key: K) => p[key];
}

const pointGetter = getter<Point>(p);

Теперь пройдены те же тесты, что и выше:

const x = pointGetter('x');
if (typeof x === 'number') {
    console.log(x.toFixed());
} else {
    console.log(x.toLowerCase());
}

const z = pointGetter('z');
if (z) {
    console.log('Point 3d with z = ' + z.toFixed())
} else {
    console.log('Point2d')
}

И это можно использовать без явного промежуточного объекта (хотя это выглядит немного необычно):

const x = getter<Point>(p)('x');
...