typescriptfp-ts

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


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?


Solution

  • 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"))
    );