Почему .filter() неправильно сужает элементы при проверке свойства типа объединения?

В TypeScript 5.5 .filter() стал лучше сужать тип вывода, однако, похоже, он не сужается правильно при проверке свойства объекта, типизированного объединением.

Рассмотрим следующие два примера (TS Playground):

const example1 = [{ foo: 123 }, { foo: null }]
    .filter(item => {
        //   ^? (parameter) item: { foo: number; } | { foo:…
        return item.foo != null;
    });
const thisIsOK = example1;
//       ^? const thisIsOK: { foo: number; }[]

const example2 = [{ foo: 123 }, { foo: null }]
    .map(({ foo }) => ({ foo }))
    .filter(item => {
        //   ^? (parameter) item: { foo: number | null; }
        return item.foo != null;
    });
const notOK = example2;
//      ^? const notOK: { foo: number | null; }[]

Обратите внимание, что в первом примере каждый элемент имеет свой тип (дискриминируемое объединение?). Однако во втором примере мы создали новый объект в .map(), который «объединил» объединение с одним типом и «переместил» его в свойство foo. Сейчас .filter() больше не работает. Почему бы и нет и какое решение?

Вы все равно можете помочь этому с помощью явного предиката типа возвращаемого типа.

jonrsharpe 17.07.2024 00:38

@zerkms Я вижу, что проблема закрыта, но это все еще не решает проблему? Нужно ли нам по-прежнему использовать защиту типа/предикаты типа? Я посмотрю PR, но сейчас 2 часа ночи.

kjetilh 17.07.2024 02:04

@jonrsharpe да, возможно. Однако это немного сложнее, когда тип объекта сложный.

kjetilh 17.07.2024 02:04

Это не работает, поскольку {foo: number | null} не является дискриминируемым объединением, поэтому проверка свойства foo не сужает родительский объект; см. ms/TS#42384 . Я не уверен, какое это «решение»; в примере вы просто вручную пишете предикат типа; в противном случае вы можете написать помощника narrowParent, как показано по ссылке на эту игровую площадку. Это полностью решает вопрос? Если да, то я напишу ответ или найду подходящий источник дублирования; если нет, то что мне не хватает?

jcalz 17.07.2024 03:11

Хорошо @jcalz, но мы все равно получаем { foo: number | null; } как часть родительского союза, возможно ли в итоге получить только { foo: number; }[]?

kjetilh 17.07.2024 11:32

(«родительский союз»? Вы имеете в виду «пересечение»?) Вам решать, как написать предикат типа, который соответствует вашим потребностям. Вместо этого вы могли бы написать это вот так. Но варианты использования и крайние случаи необходимо тестировать, несмотря ни на что. Итак, мне продолжить ответ или я все еще что-то упускаю?

jcalz 17.07.2024 14:15

Я нашел простое решение, просто вернув разные типы в файле .map() @jcalz. Я опубликовал это как ответ, но подумал, что ваше предложение также может быть полезным, поэтому я призываю вас опубликовать его, если вы хотите.

kjetilh 17.07.2024 21:36

Хм, я думал, что .map(({ foo }) => ({ foo })) было предпосылкой вопроса, я не осознавал, что это то, что вы бы изменили (поскольку теперь вы проверяете foo != null дважды).

jcalz 17.07.2024 22:00

Да, к счастью, в моем случае я мог изменить код, но в тех случаях, когда вы не можете этого сделать, ваше решение определенно подойдет. Хотя вторая проверка на ноль немного неудобна, я думаю, что это хорошее и простое решение, которое я предпочитаю защите типа. Будем надеяться, что в конечном итоге TypeScript достигнет точки, когда .filter() будет работать без дополнительной гимнастики.

kjetilh 18.07.2024 01:10
Зод: сила проверки и преобразования данных
Зод: сила проверки и преобразования данных
Сегодня я хочу познакомить вас с библиотекой Zod и раскрыть некоторые ее особенности, например, возможности валидации и трансформации данных, а также...
Как заставить Remix работать с Mantine и Cloudflare Pages/Workers
Как заставить Remix работать с Mantine и Cloudflare Pages/Workers
Мне нравится библиотека Mantine Component , но заставить ее работать без проблем с Remix бывает непросто.
Угловой продивер
Угловой продивер
Оригинал этой статьи на турецком языке. ChatGPT используется только для перевода на английский язык.
TypeScript против JavaScript
TypeScript против JavaScript
TypeScript vs JavaScript - в чем различия и какой из них выбрать?
Синхронизация localStorage в масштабах всего приложения с помощью пользовательского реактивного хука useLocalStorage
Синхронизация localStorage в масштабах всего приложения с помощью пользовательского реактивного хука useLocalStorage
Не все нужно хранить на стороне сервера. Иногда все, что вам нужно, это постоянное хранилище на стороне клиента для хранения уникальных для клиента...
Что такое ленивая загрузка в Angular и как ее применять
Что такое ленивая загрузка в Angular и как ее применять
Ленивая загрузка - это техника, используемая в Angular для повышения производительности приложения путем загрузки модулей только тогда, когда они...
1
10
74
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Эту проблему можно решить, вернув тип объединения из .map() вместо одного типа, где свойство для фильтрации является типом объединения. Спасибо @jcalz за помощь в поиске этого решения.

Решение (TS Playground):

Обратите внимание, как мы возвращаем разные типы из .map() в зависимости от данных, которые хотим фильтровать.

const example3 = [{ foo: 123 }, { foo: null }]
    .map(({ foo }) => foo != null ? { foo } : undefined)
    .filter(item => {
        //   ^? (parameter) item: { foo: number; } | undefi…
        return item != null;
    });
const thisWorks = example3;
//        ^? const thisWorks: { foo: number; }[]
Ответ принят как подходящий

Проблема в том, что, как правило, сужение видимого типа свойства объекта не служит для сужения видимого типа самого объекта, если только объект не относится к типу дискриминируемого объединения и свойство не является дискриминантное свойство. Поэтому, когда вы проверяете свойство foo объекта {foo: number | null}, он не сужает объект, тогда как если вы проверяете свойство foo объекта {foo: number} | {foo: null}, он сужает (потому что null является типом единицы и, следовательно, может служить дискриминантом).

На сайте microsoft/TypeScript#42384 уже давно есть открытый запрос на изменение этого поведения. Если это когда-нибудь будет реализовано, то (item: {foo: number | null}) => item.foo != null будет автоматически выводиться как возвращающий предикат типа, и ваш код просто будет работать.

До тех пор, пока это не произойдет, вам нужно обойти это.


Общий обходной путь — создать специальную функцию защиты типа, которая сужает родительские объекты по свойствам, например:

function narrowParent<T extends object, K extends keyof T, U extends T[K]>(
    o: T, k: K, guard: (x: T[K]) => x is U
): o is Extract<{ [P in keyof T]: P extends K ? U : T[P] }, T> {
    return guard(o[k])
}

который принимает объект o типа T, один из его известных ключей k типа K и обратный вызов guard, который может сужаться от типа o[k] до некоторого более узкого типа U и который сужает o себя до типа, который выглядит как T кроме поскольку свойство K сужается. Например:

const x = {
    bar: "abc",
    baz: Math.random() < 0.5 ? 123 : undefined,
    qux: new Date()
}
// const x: { bar: string; baz: number | undefined; qux: Date; } 
if (narrowParent(x, "baz", x => x !== undefined)) {
    // const x: { bar: string; baz: number; qux: Date; } 
}

Вот проверяем x.baz и оно x само сужается.

Вооружившись этим, вы можете переписать его как

const example2 = [{ foo: 123 }, { foo: null }]
    .map(({ foo }) => ({ foo }))
    .filter(item => narrowParent(item, "foo", x => x != null));
const nowOK = example2;
//      ^? const nowOK: { foo: number; }[]

Конечно, для какого-то конкретного примера вам может не понадобиться полное общее решение. Вы можете решить, что нужно просто пропустить весь подход «map-then-filter» в пользу подхода FlatMap():

const example3 = [{ foo: 123 }, { foo: null }]
    .flatMap(({ foo }) => (foo != null ? [{ foo }] : []));
// const example3: { foo: number; }[]

Детская площадка, ссылка на код

Другие вопросы по теме