Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

key lens with a default value #49

Open
hanazuki opened this issue Oct 14, 2023 · 2 comments
Open

key lens with a default value #49

hanazuki opened this issue Oct 14, 2023 · 2 comments

Comments

@hanazuki
Copy link

hanazuki commented Oct 14, 2023

🚀 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
Copy link

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
Copy link
Author

@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')

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants