Home > Mobile >  Generically filtering on an fp-ts Option property and extracting the value
Generically filtering on an fp-ts Option property and extracting the value

Time:12-30

I often find myelf implementing the following pattern when using fp-ts:

interface Person {
    id: number;
    pet: O.Option<'dog' | 'cat'>;
}

const person: Person = { id: 1, pet: O.some('dog') };

// simplest case:
const maybePersonWithPet = pipe(
    person.pet,
    O.map(pet => ({ ...person, pet })),
);

// very often slightly more cumbersome:
const maybePersonWithPet2 = pipe(
    O.some(person),
    O.filterMap(p =>
        pipe(
            p.pet,
            O.map(pet => ({ ...p, pet })),
        ),
    ),
);

console.log(maybePersonWithPet);
console.log(maybePersonWithPet2);
// { _tag: 'Some', value: { id: 1, pet: 'dog' }

So this is an option filter but it's on a nested option property, where the value of the nested property is extracted. I would like to generalise this, so I thought to write a function that I could call as follows:

function filterAndExtractOption<O, K extends keyof O>(object: O, key: K): O.Option<Omit<O, K> & { K: any }> {
    return pipe(
        object[key] as any,
        O.map(value => ({ ...object, [key]: value })),
    ) as any;
}

const maybePersonWithPet3 = filterAndExtractOption(person, 'pet');
console.log(maybePersonWithPet3);

const maybePersonWithPet4 = pipe(
    O.some(person),
    O.chain(p => filterAndExtractOption(p, 'pet')),
);
console.log(maybePersonWithPet4);

What would the correct type definition for the filterAndExtractOption function be? I need to pass an object, a property key which must be the key for an Option<A> and I also need to extract the A type.

I also am wondering if there's a canonical and succinct way of doing this with fp-ts?

CodePudding user response:

Few things we need to take into account before we proceed:

  1. As far as I understood, key argument should represent only Option value
  2. This expression ({ ...object, [key]: value })) in TS always returns {[prop:string]: Value} indexed type instead of expected Record<Key, Value>
  3. object[key] should be treated as an Option value inside of function scope.

Let's start from the first statement 1)

In order to assure function scope that key argument represents Option value, you need to do this:

type GetOptional<Obj> = Values<{
  [Prop in keyof Obj]: Obj[Prop] extends O.Option<unknown> ? Prop : never;
}>;

const filter = <
  OptionValue,
  Obj,
  Key extends GetOptional<Obj>
>(
  obj: Obj & Record<Key, O.Option<OptionValue>>,
  key: Key
) =>
  pipe(
    obj[key],
    O.map((value) => extendObj(obj, key, value))
  );

Please see my article and SO answer for more details and context.

GetOptional - iterates through each key and checks whether value which represents this key is a subtype of O.Option<unknown> or not. If it is - it returns Prop name, otherwise - returns never.

Values - obtains a union of all values in object. Hence GetOptional<Person> returns pet , because this is a key which represents Option value.

As for the second statement 2)

I have provided helper function :


const extendObj = <Obj, Key extends keyof Obj, Value>(
  obj: Obj,
  key: Key,
  value: Value
) => ({ ...obj, [key]: value }) as Omit<Obj, Key> & Record<Key, Value>;

As for the third statement 3):

Then, we need to represent filtering in a type scope.

type InferOption<Opt> = Opt extends O.Some<infer Value> ? Value : never;

type FilterOption<Obj, Key extends GetOptional<Obj>> = {
  [Prop in keyof Obj]: Prop extends Key ? InferOption<Obj[Prop]> : Obj[Prop];
};

InferOption - extracts value from Option

FilterOption - iterates through object and checks whether Prop is a Key which in turn represents Option value. If yes - extracts option value, otherwise - returns non modified value.

Let's put it all together:

import * as O from "fp-ts/lib/Option";
import { pipe } from "fp-ts/lib/function";

interface Person {
  id: number;
  pet: O.Option<"dog" | "cat">;
}

const person: Person = { id: 1, pet: O.some("dog") };

const extendObj = <Obj, Key extends keyof Obj, Value>(
  obj: Obj,
  key: Key,
  value: Value
) => ({ ...obj, [key]: value }) as Omit<Obj, Key> & Record<Key, Value>;

type Values<T> = T[keyof T];

type InferOption<Opt> = Opt extends O.Some<infer Value> ? Value : never;

type FilterOption<Obj, Key extends GetOptional<Obj>> = {
  [Prop in keyof Obj]: Prop extends Key ? InferOption<Obj[Prop]> : Obj[Prop];
};

type GetOptional<Obj> = Values<{
  [Prop in keyof Obj]: Obj[Prop] extends O.Option<unknown> ? Prop : never;
}>;

const filter = <
  OptionValue,
  Obj,
  Key extends GetOptional<Obj>
>(
  obj: Obj & Record<Key, O.Option<OptionValue>>,
  key: Key
) =>
  pipe(
    obj[key],
    O.map((value) => extendObj(obj, key, value))
  ) as FilterOption<Obj, Key>;

const maybePersonWithPet3 = filter(person, "pet");

maybePersonWithPet3.pet; // "dog" | "cat"

Playground


In order t make it composable, just get rid of type assertions and FilterOption:

import * as O from "fp-ts/lib/Option";
import { pipe } from "fp-ts/lib/function";

interface Person {
  id: number;
  pet: O.Option<"dog" | "cat">;
}

const person: Person = { id: 1, pet: O.some("dog") };

type Values<T> = T[keyof T];

type GetOptional<Obj> = Values<{
  [Prop in keyof Obj]: Obj[Prop] extends O.Option<unknown> ? Prop : never;
}>;

const extendObj =
  <Obj, Key extends PropertyKey>(obj: Obj, key: Key) =>
  <Value,>(value: Value) =>
    ({ ...obj, [key]: value } as Omit<Obj, Key> & Record<Key, Value>);

const filter = <OptionValue, Obj, Key extends GetOptional<Obj>>(
  obj: Obj & Record<Key, O.Option<OptionValue>>,
  key: Key
) => pipe(obj[key], O.map(extendObj(obj, key)));

const maybePersonWithPet3 = filter(person, "pet");

const maybePersonWithPet4 = pipe(
  O.some(person),
  O.chain((person: Person) => filter(person, "pet"))
);

CodePudding user response:

So captain-yossarian from Ukraine gave me an answer that worked, but the return type in his solution is rather hard to read. Inspired by his answer, I managed to get some help from another person on Discord, and now have a more readable solution. I've also extended the function to take more than one key:

type Values<T> = T[keyof T];

type GetOptional<Obj> = Values<{
    [Prop in keyof Obj]: Obj[Prop] extends O.Option<unknown> ? Prop : never;
}>;

const sequenceProps = <A, Key extends GetOptional<A>>(
    a: A,
    ...keys: Key[]
): O.Option<{ [K in keyof A]: K extends Key ? (A[K] extends O.Option<infer B> ? B : A[K]) : A[K] }> => {
    const out = {} as any;
    let allSome = true;
    for (const key in a) {
        if (keys.includes(key as any)) {
            const value = a[key] as any;
            if (O.isSome(value)) {
                out[key] = value.value;
            } else {
                allSome = false;
                break;
            }
        } else {
            out[key] = a[key];
        }
    }
    return allSome ? O.some(out) : O.none;
};

I can call the function as follows:

type PetType = 'dog' | 'cat';
type VehicleType = 'bicycle' | 'car';
interface Person {
    id: number;
    pet: O.Option<PetType>;
    vehicle: O.Option<VehicleType>;
}
const person: Person = { id: 1, pet: O.some('dog'), vehicle: O.some('bicycle') };

const maybePersonWithPetAndVehicle = sequenceProps(person, 'pet', 'vehicle');

... and the return type is clean; when I hover over the variable, I get:

const maybePersonWithPetAndVehicle: O.Option<{
    id: number;
    pet: PetType;
    vehicle: VehicleType;
}>
  • Related