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?
Few things we need to take into account before we proceed:
key
argument should represent only Option
value({ ...object, [key]: value }))
in TS always returns {[prop:string]: Value}
indexed type instead of expected Record<Key, Value>
object[key]
should be treated as an Option
value inside of function scope.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.
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>;
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"
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"))
);