fp-ts / optic

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

`key` lens with a default value #49

Open hanazuki opened 8 months ago

hanazuki commented 8 months ago

šŸš€ Feature request

Current Behavior

This may be just a newbie question as I'm not familiar with fp-ts or effect-ts library, or the notion of Optics in general...

I want to read and write a property in a nested object that may not exist, as illustrated in the following snippet.

// We have a set of counters. Each counter is assigned a string key.
type Counters = { [key: string]: { counter: number } }

// At the beginning, we only have the 'foo' counter in the set.
const c: Counters = { foo: { counter: 1 } }

// This is what I want.
export const keyWithDefault = <S extends object, Key extends keyof S & (string | symbol)>(key: Key, fallback: () => S[Key]): Optic.Lens<S, S[Key]> =>
  Optic.lens(
    (s) => Object.prototype.hasOwnProperty.call(s, key) ? s[key] : fallback(),
    (b) => (s) => ({ ...s, [key]: b }),
  )

// This lens focuses on the 'bar' counter, whether it exists or not.
const _bar = Optic.id<Counters>().compose(keyWithDefault('bar', () => ({ counter: 0 }))).at('counter')

// Because 'bar' doesn't exist, the fallback value is used when accessed.
console.log(Optic.get(_bar)(c))  // => 0
console.log(Optic.replace(_bar)(3)(c))  // => { foo: { counter: 1 }, bar: { counter: 3 } }
console.log(Optic.modify(_bar)(n => n + 5)(c))  // => { foo: { counter: 1 }, bar: { counter: 5 } }

Desired Behavior

Can this be achieved by composing the existing optics in the library, instead of implementing keyWithDefault by myself?

Suggested Solution

Add an overload of the key function that takes a fallback value and returns a lens.

Who does this impact? Who is this for?

I suppose using an Object as a dictionary/map is idiomatic in TypeScript/JavaScript and this is useful in many situations.

Describe alternatives you've considered

The proposed function can be easily implemented on the user side as shown in the code snippet. (edited the code; Actually, I happened to know this might not be as easy to implement properly as I think...)

Additional context

Your environment

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

I've achieved the following with monocle + fp-ts (not fp-ts/optic yet) with the following two functions:

export const optionProp = <O, K extends keyof O>(
  k: K,
): (<R>(
  l: Lens.Lens<R, O>,
) => Lens.Lens<R, O.Option<Exclude<O[K], undefined>>>) =>
  Lens.composeLens(
    Lens.lens(
      (data) =>
        pipe(
          data[k],
          // Note: hasOwnProperty would probably be better here, but this is what I use in my codebase
          O.fromPredicate(
            (x): x is Exclude<O[K], undefined> => x !== undefined,
          ),
        ),
      O.fold(
        () => (state) => {
          const cloned = Object.assign({}, state);
          delete cloned[k];
          return cloned;
        },
        (v) => (state) => ({
          ...state,
          [k]: v,
        }),
      ),
    ),
  );

export const non = <T>(
  eq: Eq.Eq<T>,
  a: T,
): (<U>(l: Lens.Lens<U, O.Option<T>>) => Lens.Lens<U, T>) =>
  Lens.composeIso(
    Iso.iso(
      O.getOrElse(() => a),
      O.fromPredicate((x) => !eq.equals(x, a)),
    ),
  );

The combination of these allows for pretty flexible use, and allows removal of properties, which your example doesn't. As far as I know there's no support for what you're doing built into either library.

hanazuki commented 8 months ago

@kalda341 Thank you for sharing your code.

I've implemented optionProp/non based on your idea and it works great. I realized the key point is that Lens<S, Option<A>> and Optional<S, A> are different things, and also learned handling property removal in the TypeScript type system is a bit tricky.

export const optionProp = <S extends object, Key extends keyof S>(key: Key):
  Optic.Lens<S, O.Option<Exclude<S[Key], undefined>>> =>
  Optic.lens(
    (s) => O.liftPredicate((a): a is Exclude<S[Key], undefined> => a !== undefined)(s[key]),
    (a) => (s) =>
      O.match({
        onNone: () => {
          const { ...s1 } = s
          delete s1[key]
          return s1
        },
        onSome: (a) => ({ ...s, [key]: a }),
      })(a)
  )

export const non = <T>(
  a: T,
): Optic.Iso<O.Option<T>, T> =>
  Optic.iso(
    O.getOrElse(() => a),
    O.liftPredicate(x => x !== a),
  )

type Counters = { [key: string]: { counter: number } }
const c: Counters = { foo: { counter: 1 } }

const _bar = Optic.id<Counters>().compose(optionProp('bar')).compose(non({ counter: 0 })).at('counter')