fp-ts / optic

A porting of zio-optics to TypeScript
https://fp-ts.github.io/optic/
MIT License
113 stars 3 forks source link

`filter` on array indices #54

Open Clindbergh opened 8 months ago

Clindbergh commented 8 months ago

šŸš€ Feature request

Current Behavior

Currently a specific number is needed to modify an array element at a specific index.

type User = {
  uuid: string;
  cats: ReadonlyArray<{ uuid: string, likesMice: boolean }>
} 

const _desiredFilter: Optional<
  User,
  ReadonlyArray<Filter>
> = Optic.id<User>().at('user').at('cats').index(2)

In most cases I must find the desired index beforehand, e.g. by iterating on all elements within the array and find the required id.

Desired Behavior

Create a lens on all elements within the array satisfying the specific predicates, similar to filter.

Example with filter for keys:

const _desiredFilter: Optional<
  UserState,
  ReadonlyArray<Filter>
> = Optic.id<UserState>().at('user').filter(user => user.uuid === 'desiredUUID');

Desired behaviour for index elements.

const _desiredFilter: Optional<
  UserState,
  ReadonlyArray<Filter>
> = Optic.id<UserState>().at('user').at('cats').filter(cat => cat.likesMice);

Suggested Solution

Implement a new method filterIndex similar to filter that returns an Optional.

Who does this impact? Who is this for?

Anyone who uses arrays.

Describe alternatives you've considered

Alternatively a lens factory method may be created, but this is not as clean as it could be.

Your environment

Software Version(s)
@fp-ts/optic 0.10.0
TypeScript 5.1.3
Clindbergh commented 8 months ago

I realize there would potentially have to be two functions: One to zoom into all elements within that array (focusing on a subset of the array) and one that would only look at the first Element.

I suggest an implementation along these lines for the latter:

const firstIndexBy: {
    <S extends A, B extends A, A = S>(
        refinement: Refinement<A, B>,
        message?: string
    ): Optional<ReadonlyArray<A>, A>
    <S extends A, A = S>(predicate: Predicate<A>, message?: string): Optional<ReadonlyArray<A>, A>
} = <S>(predicate: Predicate<S>, message?: string): Optional<ReadonlyArray<S>, S> =>
    optional(
        (s) =>
            pipe(
                s,
                ReadonlyArray.findFirstIndex(predicate),
                Option.flatMap(i => ReadonlyArray.get(s, i)),
                Either.fromOption(() => new Error(message ?? `No index matching predicate`))
            ),
        (a) =>
            (s) =>
                pipe(
                    s,
                    ReadonlyArray.findFirstIndex(predicate),
                    Option.flatMap(i => ReadonlyArray.replaceOption(i, a)(s)),
                    Either.fromOption(() => new Error(`No index matching predicate`))
                )
    )

Would you be open for PRs?

Clindbergh commented 8 months ago

findFirst

I realize a function such as the one I suggested is already available.

https://github.com/fp-ts/optic/blob/55ffb9e8b5e01c8483795adb19082fba6ef2a052/src/index.ts#L657

findAll or elementsBy

I suggest a similar function to access a subset of an array.

An example implementation


/**
 * Returns a Lens to a subset of array elements satisfying a predicate
 *
 * If the same number of elements are passed to the setter, they are applied to the selected elements.
 * If an empty array is passed to the setter, the elements remain unchanged
 * If less than the number of elements are passed to the setter, the new elements are cycled
 * If more than the number of elements are passed to the setter, only the first number of elements satisfying the predicate
 * are used
 *
 * @example
 *
 * const numbers: ReadonlyArray<number> = makeBy(10, x => x)
 * const evenNumbers: Lens<ReadonlyArray<number>, ReadonlyArray<number>> = elementsBy<number, number>(number => number % 2 === 0);
 *
 * console.log(numbers)
 * // [
 * //   0, 1, 2, 3, 4,
 * //   5, 6, 7, 8, 9
 * // ]
 * console.log(get(evenNumbers)(numbers))
 * // [ 0, 2, 4, 6, 8 ]
 * console.log(modify(evenNumbers)(map(x => x*2))(numbers))
 * // [
 * //   0,  1, 4,  3, 8,
 * //   5, 12, 7, 16, 9
 * // ]
 * console.log(replace(evenNumbers)([-1])(numbers))
 * // [
 * //   -1,  1, -1,  3, -1,
 * //    5, -1,  7, -1,  9
 * // ]
 *
 *
 * @param predicate     The predicate to select the elements
 */
export const elementsBy: {
  <S extends A, B extends A, A = S>(
    refinement: Refinement<A, B>,
  ): Optional<ReadonlyArray<A>, A>;
  <S extends A, A = S>(predicate: Predicate<A>): Lens<
    ReadonlyArray<A>,
    ReadonlyArray<A>
  >;
} = <S>(predicate: Predicate<S>): Lens<ReadonlyArray<S>, ReadonlyArray<S>> =>
  lens(
    (a): ReadonlyArray<S> => filter(predicate)(a),
    (s) => (a) =>
      pipe(
        a,
        reduce(
          { as: empty(), si: 0 },
          ({ as, si }: { as: ReadonlyArray<S>; si: number }, a, i) =>
            predicate(a)
              ? { as: as.concat(s.at(si % s.length) ?? a), si: si + 1 }
              : { as: as.concat(a), si },
        ),
        (x) => x.as,
      ),
  );

Open questions