extraxt
extraxt

Reputation: 425

Typescript and Array.filter() working together

When using Array.filter() I'm not sure how to achieve what I'm describing below.

I don't want to create a new type just for this (but if there's no other way around, that's okay):

interface GPSLocation {
  lat: number
  lng: number
}

interface House {
  address: string
  location?: GPSLocation
}

const house01: House = {
  address: '123 street'
}

const house02: House = {
  address: '123 street',
  location: {
    lat: 111.111,
    lng: 222.222
  }
}

const allHouses = [house01, house02]

// Infered by Typescript: const gpsLocationList: (GPSLocation | undefined)[]
// Expected: const gpsLocationList: (GPSLocation)[]
const gpsLocationList = allHouses.filter((house) => house.location !== undefined).map(house => house.location)

Upvotes: 6

Views: 1821

Answers (2)

Itay Maman
Itay Maman

Reputation: 30723

Indeed annoying.

Here are two common approaches.

(1) Use as to force the compiler to treat it as GPSLocation:

const gpsLocationList = allHouses.filter((house) => house.location !== undefined)
  .map(house => house.location as GPSLocation)

(2) Use a function that throws an error if its input is falsy:

function reify<T>(t: T|null|undefined): T {
  if (t === null || t === undefined) {
    throw new Error(`got a falsy value`)
  }
  return t
}

const gpsLocationList = allHouses.filter((house) => house.location !== undefined)
  .map(house => reify(house.location))

Update

There is actually a third approach: use flatMap()

const gpsLocationList: string[] = allHouses.flatMap(
  house => house.location !== undefined ? [house.location] : [])

This actually works well and is 100% safe. The downsides are:

  • .flatMap() is not available in all platform. You will need to use target: "es2019" in your tsconfig.json file.
  • It's not very readable.

Upvotes: 2

Ruan Mendes
Ruan Mendes

Reputation: 92274

Make your filter callback be a type guard that forces location not to be optional.

I know there's an existing post with a great answer, but since it's not that easy to infer the solution for this case (mapping to an optional subproperty), and I had to do the same thing in the past few days, I will share this here.

const gpsLocationList = allHouses
    .filter((house): house is House & {location: GPSLocation} => {
         return house.location !== undefined;
    })
    .map(house => house.location);

I would prefer to split this into a separate declaration for clarity.

type HouseLocationRequired = House & {location: GPSLocation};

const gpsLocationList = allHouses
    .filter((house): house is HouseLocationRequired => {
         return house.location !== undefined;
    })
    .map(house => house.location);

If you want to really minimize duplication of types, you can use Required<T> and Pick<>

type HouseLocationRequired = House & Required<Pick<House, "location">>;

And abstract it one level further:

type RequiredSubProperty<Class, Prop extends keyof Class> = Class & Required<Pick<Class, Prop>>;

const gpsLocationList = allHouses
    .filter((house): house is RequiredSubProperty<House, "location">  => {
         return house.location !== undefined;
    })
    .map(house => house.location);

At that point, you may as well abstract checking the sub-property with type safety.

function subPropFilter<T>(prop: keyof T) {
    return (obj: T): obj is RequiredSubProperty<T, typeof prop> => {
        return obj[prop] !== undefined;
    }
}

const gpsLocationList = allHouses.
    .filter(subPropFilter("location"))
    .map(house => house.location)

Then why not abstract the entire process adding mapping to that sub-property?

function getNonNullSubProps<T, Prop extends keyof T>(arr: T[], prop: Prop) {
    return arr.filter(subPropFilter(prop)).map(obj => obj[prop] as typeof obj[Prop]) 
}

const gpsLocationList = getNonNullSubProps(allHouses, "location");

See live example that shows how awesome the suggestions are as you're using these utilities.

Upvotes: 9

Related Questions