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鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Some struct Functions #1460

Closed
anthonyjoeseph opened this issue Mar 30, 2021 · 37 comments 路 May be fixed by gcanti/monocle-ts#180
Closed

Some struct Functions #1460

anthonyjoeseph opened this issue Mar 30, 2021 · 37 comments 路 May be fixed by gcanti/monocle-ts#180

Comments

@anthonyjoeseph
Copy link
Contributor

anthonyjoeseph commented Mar 30, 2021

馃殌 Feature request

Current Behavior

Desired Behavior

I have some ideas for new functions to go in the struct module that might be handy:
Functions & Use cases

  • evolve

Already exists. My idea modifies it to allow properties to be omitted.

assert.deepStrictEqual(
  pipe(
    { a: 'a', b: true, c: 'abc' },
    evolve({
      a: (s) => s.length,
      b: (b) => !b,
    })
  ),
  { a: 1, b: false, c: 'abc' }
)
  • omit & pick

Stolen from fp-ts-std - I love these functions and I think they might be a great fit here! They're great for omitting data at both type & value level

  interface Event { type: 'login'; username: string; } | { type: 'submit'; date: Date }
  declare const eventData: Event
  analytics.track(eventData.type, _.omit(['type'])(eventData))
  • refine

Refines a struct based on some of its properties

declare const employees: { userId: string; age: number; position: string; ... }[]
const seniorManagement: { userId: string; age: number; position: 'Manager'; ... }[] = pipe(
  employees,
  Array.map(refine({
    age: (a) => a > 50,
    position: (p): p is 'Manger' => p === 'Manager',
  })),
  Array.compact,
)
  • parse

Parses an struct based on some of its properties

Might be good for heterogeneous html form data

interface User { created: Date; age: number; password: string }
const validatePassword = (p: string): E.Either<string[], string> => ...
const setErrors = (e: { age?: string; password?: string[] }) => ...
const registerUser = (u: User) => ...

declare const formData: { created: Date; age: string; password: string }
const onSubmit = () => pipe(
  formData,
  parse({
    age: E.tryCatchK(parseInt, () => 'Age must be a number'),
    password: validatePassword,
  }),
  E.match(setErrors, registerUser)
)
  • traverseS

Might be likewise useful for forms that only want a single error message (i.e. combined w/ a Validation)
I almost implemented a refineMap, but I realized that traverseS would be a more general solution.

  • compactS and unCompact

Useful for structures that contain heterogeneous optional values

const c: { a?: string, b: number } = compactS({ a: O.some('thirty'), b: 44, c: O.none })
const d: { a: O.Option<string>, b: O.Option<number> } = unCompact({ a: 'thirty', b: undefined as number | undefined })

Do (let, bindTo, bind)

Pipe-able helpers to build an object. Point-free ergonomics that prevent mistakes & boilerplate

import * as S from 'fp-ts/struct'
const a: { a: number; b: boolean; c: string } = pipe(
  3,
  S.bindTo('a'), // alternately, pipe(S.Do, S.let('a', 3), ...)
  S.let('b', false),
  S.bind('c', ({a}) => a.toString()),
)

set & setW

Typesafe port of lodash's set. set is especially helpful for creating generic Endomorphisms (various modifyAts, State.modify etc.)

import {modifyHead} from 'fp-ts/ReadonlyNonEmptyArray'
const setFirstAToOne = modifyHead(set('a', 1))
const y = pipe(
  [{ a: 2 }, { a: 2 }] as const,
  setFirstAToOne,
)
// Like `chainW`, `setW` can widen the type of its input
const z: { a: boolean } = setW('a', false)({ a: 3 })

Suggested Solution

Here are rough draft implementations

Who does this impact? Who is this for?

Developers who want to transform simple structed data w/ strong type safety

Describe alternatives you've considered

Maybe some of these could start off in fp-ts-contrib, like sequenceS did?

Additional context

This started off as a PR (which was premature, since the features at the time weren't necessarily desired)

I'm not sure what to do about the S suffix of compactS - the S suffix is redundant since the function is already in the struct namespace, but compact doesn't seem quite right since its function signature doesn't fit inside Compactable.compact.

Your environment

Software Version(s)
fp-ts 3.0.0
TypeScript 4.2.3
@qlonik
Copy link
Contributor

qlonik commented Mar 31, 2021

Copy-paste from functional programming slack.

I would also like to see this module. It would be nice if it had functions like setAt, getAt, insertAt, mapAt, removeAt, createAt. me and @r-cyr had a discussion about this at some point. I'll post a snippet of functions and their signatures that we came up with. Looking at the initial post snippet, there are some methods that already cover some of these. e.g. evolve is really nice and covers mapAt.We have also discussed issue similar to unsafety of singleton function for the case of setAt (since you can say sayAt<'a' | 'b', number>('a', 123) and that will break things) and came up with the following: https://tsplay.dev/mxoxBN (see 'EnsureLiteral' type in the link)

some very rough code that i'm not even sure works, but has some ideas

function setAt<T, Key extends keyof T>(key: Key, value: T[Key]) {
  return (t: T): T => ({ ...t, [key]: value })
}

function insertAt<Key extends string, V>(key: EnsureLiteral<Key>, value: V) {
  return <T extends object>(
    t: EnsurePropertyNotExist<T, Key>
  ): T & { readonly [K in Key]: V } => {
    return {
      ...t,
      [key]: value
    } as any
  }
} 

export function createAt<T, K extends string, A>(
   key: K extends keyof T ? never : K,
   fn: (obj: T) => A
 ) {
   return (obj: T): T & { readonly [k in K]: A } => ({ ...obj, [key]: fn(obj) });
 } 

export function replaceAt<K extends string, V>(key: K, value: V) {
   return <T extends { [k in K]: unknown }>(obj: T): Omit<T, K> & { readonly [k in K]: V } => ({ ...obj, [key]: value });
 } 

export function removeAt<K extends string>(key: K) {
   return <T extends { [k in K]: unknown }>({ [key]: _, ...rest }: T): Omit<T, K> => rest;
 }

@anthonyjoeseph
Copy link
Contributor Author

anthonyjoeseph commented Mar 31, 2021

This is wonderful! I have some ideas:

  • maybe replaceAt could just be an overload of setAt?
  • is there a reason for keeping both removeAt and omit?
  • Maybe getAt could just be named at? Or is that too similar to the monocle function? Or is that a good thing? prop has also been suggested, which also has monocle overlap

Thoughts?

@cdimitroulas
Copy link
Contributor

Just seen this - happy to rename prop in #1454 to getAt or similar name which fits in better with the rest of these proposals if we think this is likely to go ahead.

@anthonyjoeseph
Copy link
Contributor Author

I'm not sure if @qlonik has any input, but my 2 cents is to go ahead w/ prop - the spec is still getting nailed down here, and it seems like prop has some momentum behind it

@qlonik
Copy link
Contributor

qlonik commented Apr 1, 2021

I think so too if prop is close to completion, you probably shouldn't wait for us. Also, all of the names setAt, removeAt, and others were mostly placeholders - maybe there are much better alternatives someone can come up with.

As for other points:

maybe replaceAt could just be an overload of setAt?

Possibly? Those two functions are a little different - one is preserving incoming object type T and another Is changing it. There is another point that in setAt the constraints on key and value are imposed by the object coming in later, where is in replaceAt, both key and value constrain the object type coming in later. I think we need to investigate if it is a reasonable approach for both of these functions or if we need to keep constraints only in one direction and that those two work when they are created as overloads. Also, if we do make them as overloads, I would try to make sure that setAt overload is provided before replaceAt overload, so it has priority over the other one.

is there a reason for keeping both removeAt and omit?

No. I just didn't go through the proposal very thoroughly 馃槃. Looking at omit in fp-ts-std, I don't like usage of delete. Maybe we could avoid using it, when implementing here.

Maybe getAt could just be named at? ... prop has also been suggested

I'm all for using better names than those initial setAt, removeAt and such. We could peek at lodash and rambda for good names too. I'm not sure if the overlap with monocle is good or bad.

@anthonyjoeseph
Copy link
Contributor Author

anthonyjoeseph commented Apr 1, 2021

Re: replaceAt vs setAt

It's possible to do both, though it doesn't always work when a function parameter expects an Endomorphism (Edit: e.g. Array.modifyAt). Do you have ideas for it's/their use case(s)?

Re: omit

I actually like the delete - for example, in the use case above we wouldn't want to include type: undefined. It also seems to fit its original definition of a value-level Omit a bit closer Why don't you like it?

Re: names

lodash has a set, ramda has a set. I'm leaning towards set, since it's essentially a special use case of monocle's set.

Btw I noticed that neither lodash nor ramda have something like an insertAt or createAt - in light of @gcanti 's opinions on the matter, do you have ideas for use cases for these?

@anthonyjoeseph
Copy link
Contributor Author

anthonyjoeseph commented Apr 1, 2021

Re: omit

Oh my mistake - I just looked at the implementation of replaceAt, the destructuring trick is quite brilliant - agreed that this looks ideal, especially given delete's performance issues. I'm likewise not sure how to apply it to omit - I'll keep thinking

(Edit: figured it out)

@qlonik
Copy link
Contributor

qlonik commented Apr 1, 2021

Re: omit

I too was going through docs to figure out the performance issues of delete keyword. It seems problematic from various sources (1, 2, 3). I would usually use rest spread in the object to grab everything except that one key. However, in the reference 3, I noticed someone bringing up valid concern that such spread might be even slower: 1st comment and 2nd comment.

Looks like we could use delete, but there might be penalty. Lodash too uses delete in this case, it seems. Alternatively, we could copy the entire object, while omitting certain properties specified in the filter array.

I would also note that in the link, reduce and spread produces a lot of copies of the object. It would be better to implement it as for loop. This way the object is only allocated once and properties would be copied into it.

Re: names

insertAt and createAt mirror the functionality of Do.let and Do.bind that exists in fp-ts-contrib. I thought these two functions might be useful for inserting more things into the struct.

Re: replaceAt and setAt

I find that it would be nice to merge them, but it seems that when we are trying to merge, the complexity of types increases significantly. To me, keeping them separate is somewhat like the existence of chain and chainW group of functions. One is explicitly failing when the type does not match, and another one widens types.

EDIT: omit with for loop

@anthonyjoeseph
Copy link
Contributor Author

anthonyjoeseph commented Apr 1, 2021

Re: Do

Oh that's interesting. It sounds like you're describing this kind of thing - does this look right?

const Do = {}

const a: { b: number; c: string } = pipe(
  Do,
  let('b', 3),
  let('c', 'xyz'),
  bind('d', ({b}) => b+ 1),
)

In that case, is there a use case where that's easier/simpler than this?

const a = pipe(
  {},
  (obj) => ({ ...obj, b: 3 }),
  (obj) => ({ ...obj, c: 'xyz' }),
  (obj) => ({ ...obj, b: obj.b + 1 })
)

Re: setAt

I agree - though I'm still concerned that set('a', 3) might not be terribly useful beyond (o) => ({ ...o, a: 3 }). Maybe there's a compelling case where it's useful to have a <T extends {a: number}>(t: T) => T? (Edit: I'm trying to think of something like the contramap idea here)

I think we're on the same page about replaceAt vs setAt. Also about omit - thanks for doing that research! I'll update the rough draft accordingly

(Edit: updated examples)

@qlonik
Copy link
Contributor

qlonik commented Apr 1, 2021

Re: Do

Yep, it is similar to the arrow function example. I find point-free style simpler and it removes some of the plumbing with respect to returning an object, not forgetting to copy other props and such.

Re: setAt

It is similar to the above, where it provides a nice point-free function. It has a difference between o => ({ ...o, a: 3 }). The difference is that set preserves the typeT of an object, whereas the arrow function acts more like replaceAt, where it is overwriting the property. Here is an example which shows it well for the case of literal type

@anthonyjoeseph
Copy link
Contributor Author

Ok - I'm convinced! I added everything to the spec above and updated the rough draft. I went w/ Do notation's names, though I'm not sure that's accurate since there's no chain being invoked.

I believe the ball's in @gcanti 's court now - ofc no pressure and no rush.

Maybe this is a couple (or a few?) PRs?

@gcanti
Copy link
Owner

gcanti commented Apr 2, 2021

Maybe some of these could start off in fp-ts-contrib, like sequenceS did?

I can transfer this issue there if you want to.

As a general observation I would try to come up with a few orthogonal APIs (evolve + pick + omit?) and then maybe add something else for ergonomic reasons.

First of all (just as a base line a without egonomic concerns) let's see what we can do now with the existing APIs:

evolve

do notation (Identity monad):

import * as I from 'fp-ts/Identity'

const evolveOut = pipe({ a: 'a', b: 1, c: 'abc' }, ({ a, b, ...rest }) =>
  pipe(
    rest,
    I.bind('a', () => a.length),
    I.bind('b', () => b * 2)
  )
)
assert.deepStrictEqual(evolveOut, { a: 1, b: 2, c: 'abc' })

pick

const pickOut = pipe({ a: 1, b: 'two', c: [true] }, ({ a, c }) => ({ a, c }))
assert.deepStrictEqual(pickOut, { a: 1, c: [true] })

omit

const omitOut = pipe({ a: 1, b: 'two', c: [true] }, ({ b, ...rest }) => rest)
assert.deepStrictEqual(omitOut, { a: 1, c: [true] })

refine

When guards will be implemented:

import * as O from 'fp-ts/Option'

const guard = (p: boolean): O.Option<void> => (p ? O.some(undefined) : O.none)

const refineOut1 = pipe(
  O.some({ a: 'a', b: 1, c: 'abc' }),
  O.chainFirst((x) => guard(x.a === 'a' && x.b === 1))
)
assert.deepStrictEqual(refineOut1, O.some({ a: 'a', b: 1, c: 'abc' }))
const refineOut2 = pipe(
  O.some({ a: 'a', b: 2, c: 'abc' }),
  O.chainFirst((x) => guard(x.a === 'a' && x.b === 1))
)
assert.deepStrictEqual(refineOut2, O.none)

Note that you are not restricted to the Option effect:

import * as E from 'fp-ts/Either'

const guardEither = <E>(e: () => E) => (p: boolean): E.Either<E, void> =>
  p ? E.right(undefined) : E.left(e())

const refineOut3 = pipe(
  E.right({ a: 'a', b: 2, c: 'abc' }),
  E.chainFirst((x) => guardEither(() => 'error a')(x.a === 'a')),
  E.chainFirst((x) => guardEither(() => 'error b')(x.b === 1))
)
assert.deepStrictEqual(refineOut3, E.left('error b'))

parse

do notation (Either monad):

const parseOut1 = pipe({ a: 'a', b: 1, c: 'abc' }, ({ a, b, ...rest }) =>
  pipe(
    E.right(rest),
    E.bind('a', () => (a === 'a' ? E.right(1) : E.left(`Not 'a'`))),
    E.bindW('b', () => (b === 1 ? E.right('a') : E.left(42)))
  )
)
assert.deepStrictEqual(parseOut1, E.right({ a: 1, b: 'a', c: 'abc' }))

const parseOut2 = pipe({ a: 'b', b: 1, c: 'abc' }, ({ a, b, ...rest }) =>
  pipe(
    E.right(rest),
    E.bind('a', () => (a === 'a' ? E.right(1) : E.left(`Not 'a'`))),
    E.bindW('b', () => (b === 1 ? E.right('a') : E.left(42)))
  )
)
assert.deepStrictEqual(parseOut2, E.left(`Not 'a'`))

traverseS

do notation:

const traverseSOut = pipe(
  O.some({ a: 1, b: 'b', c: 'abc' }),
  O.chain(({ a, b, ...rest }) =>
    pipe(
      O.some(rest),
      O.bind('a', () => (a <= 2 ? O.some(a.toString() + b) : O.none)),
      O.bind('b', () => (b.length <= 2 ? O.some(b.length) : O.none))
    )
  )
)
assert.deepStrictEqual(traverseSOut, O.some({ a: '1b', b: 1, c: 'abc' }))

compactS and unCompact

The signatures look a bit weird to me

Do (let, bindTo, bind)

do notation (Identity monad):

const doOut = pipe(
  3,
  I.bindTo('a'),
  I.bind('b', () => false),
  I.bind('c', ({ a }) => a.toString())
)
assert.deepStrictEqual(doOut, { a: 3, b: false, c: '3' })

set & setW

do notation (Identity monad):

const setOut = pipe({ a: 1, b: 'a' }, ({ a, ...rest }) =>
  pipe(
    rest,
    I.bind('a', () => 'a')
  )
)
assert.deepStrictEqual(setOut, { a: 'a', b: 'a' })

Also this looks like a special case of evolve if it allows properties to be omitted.


p.s.

refine, parse, compactS, unCompact look more related to a decoding / encoding library

@anthonyjoeseph
Copy link
Contributor Author

Wow this is all wonderful - what a great resource, thanks for doing all that!

Hmm I will think about how to condense these into separate apis - out of curiosity, do you think that evolve, pick & omit belong together, or separately?

fp-ts-contrib

Do you have some way to migrate this issue & all of its comments? That'd be great! I think this belongs there at this point

evolve

Are you saying there's some benefit to the current restriction requiring transformations for all keys? I'm also realizing that this one should maybe just be it's own issue on 'fp-ts', since evolve already exists

refine

Stoked about guards! Is there some way to narrow the type of the output? (Though maybe that's part of what you meant by 'ergonomics aside')

parse

Is there some way to do this that collects all the errors, instead of short circuiting?

traverseS

Same question as above - could we collect them (w/ a Validation applicative, for instance)?

compactS / unCompact

Could you be more specific? Do they look incorrect, dangerous, or maybe not useful?

Do

This looks like a bit of a killer to me - @qlonik unless you disagree I don't think I'll move forward w/ these few functions, it looks more or less the same as our implementations

Maybe there ought to be a separate issue upgrading all bind and bindTo functions to use EnsureLiteral

omit, pick, set, setW

I suppose these ones do really come down to ergonomics. I still think they're useful, but worth noting. If we're switching to fp-ts-contrib I think I'll leave out omit & pick, since they're in fp-ts-std for now.

encoding / decoding lib

I see your point - even from their names. These don't quite seem to fit in io-ts to me - do you have something else in mind, or maybe something new altogether? I see parse and refine as adjacent to the traverseS use case, and as related to the reduce / partition family of functions, which was how I justified them in my head

@gcanti
Copy link
Owner

gcanti commented Apr 2, 2021

Do you have some way to migrate this issue & all of its comments? That'd be great! I think this belongs there at this point

There's a "Transfer issue" feature in GitHub, should migrate everything.

Are you saying there's some benefit to the current restriction requiring transformations for all keys?

I think that your idea to allow properties to be omitted is good:

  • no need to define a transformer for each field
  • maybe we don't need set / setW

Is there some way to narrow the type of the output?

Ah I see... more like a Refinement constructor then? (something that might go into the Refinement module)

export type Employee<P extends string> = {
  readonly userId: string
  readonly age: number
  readonly position: P
}

declare const employees: ReadonlyArray<Employee<string>>

//               v-- how to build this, right?
declare const isManager: (e: Employee<string>) => e is Employee<'Manager'>

export const seniorManagement: ReadonlyArray<Employee<'Manager'>> = pipe(
  employees,
  RA.filter(isManager)
)

Is there some way to do this that collects all the errors, instead of short circuiting?

You need a suitable Apply instance;

import * as assert from 'assert'
import { pipe } from '../src/function'
import * as E from '../src/Either'
import * as S from '../src/string'
import { intercalate } from '../src/Semigroup'
import { apS } from '../src/Apply'

const myApS = apS(
  E.getApplicativeValidation(pipe(S.Semigroup, intercalate(' | ')))
)

const parseOut1 = pipe({ a: 'b', b: 2, c: 'abc' }, ({ a, b, ...rest }) =>
  pipe(
    E.right(rest),
    myApS('a', a === 'a' ? E.right(1) : E.left(`not 'a'`)),
    myApS('b', b === 1 ? E.right('a') : E.left('not 1'))
  )
)
assert.deepStrictEqual(parseOut1, E.left(`not 'a' | not 1`))

RE: compactS / unCompact. Could you be more specific? Do they look incorrect, dangerous, or maybe not useful?

Yeah sorry.

unCompact (decoding)

type T = { a: string; b?: number }
declare const t: T
const x = unCompact(t)
/*
const x: {
    a: Option<string>;
    b?: Option<number> | undefined;
}
*/

the type of the field b is Option<number> | undefined which I find kind of weird.

compactS (encoding)

/*
const x: {
    foo?: number | undefined;
    baz?: string | undefined;
} & {
    bar: number;
}
*/
const x = compactS({
  foo: O.some(123),
  bar: 22,
  baz: O.some('abc')
})

The result is ok but is not configurable. What if I want to encode none to null instead?

That's why I think that they are more related to a decoding / encoding library

These don't quite seem to fit in io-ts to me

With a library like io-ts I can choose how to decode (i.e. parse and/or refine) and encode any data structure (Optional fields included).

@gcanti
Copy link
Owner

gcanti commented Apr 3, 2021

CRUD operations if we had prop, pick, omit, evolve:

import { constant, pipe } from '../src/function'
import * as I from '../src/Identity'

// the following signatures are temporary...

export declare const prop: <A, K extends keyof A>(k: K) => (a: A) => A[K]

export declare const pick: <A, K extends keyof A>(
  ks: ReadonlyArray<K>
) => (a: A) => Pick<A, K>

export declare const omit: <A, K extends keyof A>(
  ks: ReadonlyArray<K>
) => (a: A) => Omit<A, K>

export declare const evolve: <
  A,
  T extends { [K in keyof A]?: (a: A[K]) => unknown }
>(
  transformations: T
) => (
  a: A
) => {
  readonly [K in keyof A]: T[K] extends (a: A[K]) => unknown
    ? ReturnType<T[K]>
    : A[K]
}

//
// CRUD
//

const input = { a: 'a', b: 1, c: true }

// Create

//                                       v-- without context
export const create1 = pipe(input, I.apS('d', new Date()))
export const create2 = pipe(
  input,
  //             v-- with context
  I.bind('d', ({ b }) => new Date(b))
)

// Read

export const read1 = pipe(input, prop('a'))

// Modify / Update

//                                                    v-- same type
export const modify1 = pipe(input, evolve({ a: (s) => s + '!' }))
//                                                    v-- different type
export const modify2 = pipe(input, evolve({ a: (s) => s.length }))
//                                                       v-- same type
export const update1 = pipe(input, evolve({ a: constant('s') }))
//                                                       v-- different type
export const update2 = pipe(input, evolve({ a: constant(1) }))

// Delete

export const delete1 = pipe(input, omit(['a']))
export const delete2 = pipe(input, pick(['b', 'c']))

// what if we want to change a key?

(as a base line) same CRUD operations using pipe + the language features:

import { identity, pipe } from '../src/function'

const readonly: <A>(a: A) => Readonly<A> = identity

//
// CRUD
//

const input = { a: 'a', b: 1, c: true }

// Create

export const create2 = pipe(input, (input) =>
  readonly({
    ...input,
    d: new Date(input.b)
  })
)

// Read

export const read2 = input.a
export const read3 = pipe(input, (_) => _.a)

// Modify / Update

export const modify1 = pipe(input, ({ a, ...rest }) =>
  readonly({ a: a + '!', ...rest })
)

export const modify2 = pipe(input, ({ a, ...rest }) =>
  readonly({ a: a.length, ...rest })
)

export const update1 = pipe(input, ({ a, ...rest }) =>
  readonly({ a: 's', ...rest })
)

export const update2 = pipe(input, ({ a, ...rest }) =>
  readonly({ a: 1, ...rest })
)

// Delete

export const delete1 = pipe(input, ({ a, ...rest }) => readonly(rest))
export const delete2 = pipe(input, ({ b, c }) => readonly({ b, c }))

//
// Update key
//

export const modifyKey1 = pipe(input, ({ a, ...rest }) => ({
  d: new Date(),
  ...rest
}))

@gcanti
Copy link
Owner

gcanti commented Apr 3, 2021

To recap:

Atomic operations

Operation Key types Value types base line ergonomic API
insert keys Changed Fixed spread syntax ?
delete keys Changed Fixed destructuring pick, omit
map keys Changed Fixed destructuring ?
map values Fixed Changed destructuring evolve
modify / update values Fixed Fixed destructuring evolve

In general we are looking for a way to build a function

transformation: structA -> F<structB>

for some effect F.

I think I found an universal pattern for all these operations that depends on omit (and maybe pick).

The pattern when F = Identity:

import { pipe } from 'fp-ts/function'
import * as I from 'fp-ts/Identity'

export declare const pick: <A, K extends keyof A>(
  ...ks: readonly [K, ...ReadonlyArray<K>]
) => (a: A) => Pick<A, K>

export declare const omit: <A, K extends keyof A>(
  ...ks: readonly [K, ...ReadonlyArray<K>]
) => (a: A) => Omit<A, K>

type Input = {
  readonly a: string | number
  readonly b: number
  readonly c: boolean
}

// universal pattern
export const transformationIdentity = (i: Input) =>
  pipe(
    i,
    omit('a', 'b'), // <= use `omit` (or `pick`) to remove unwanted keys or mapped keys/values
    I.apS('bb', i.b), // <= map keys
    I.apS('a', String(i.a)), // <= map values
    I.bind('d', ({ a }) => new Date(i.b + a)) // <= insert keys (I can possibly read from previous values using `bind`)
  )
/*
const transformationIdentity: (i: Input) => {
    readonly a: string;
    readonly c: boolean;
    readonly bb: number;
    readonly d: Date;
}
*/

The pattern when F = Option:

import * as O from 'fp-ts/Option'

export const transformationOption = (i: Input) =>
  pipe(
    i,
    omit('a'),
    O.some, // <= lift to the desired effect, `Option` in this case
    O.apS('a', typeof i.a === 'string' ? O.some(i.a) : O.none) // may fail
  )
/*
const transformationOption: (i: Input) => O.Option<{
    readonly a: string; // <= refined
    readonly b: number;
    readonly c: boolean;
}>
*/

@anthonyjoeseph note that from transformationOption we can derive a Refinement, which should solve the refine problem:

import * as RA from 'fp-ts/ReadonlyArray'

const myRefinement = O.getRefinement(transformationOption)
/*
const myRefinement: Refinement<Input, {
    readonly a: string; // <= refined
    readonly b: number;
    readonly c: boolean;
}>
*/

declare const inputs: ReadonlyArray<Input>

const refined = pipe(inputs, RA.filter(myRefinement))

However there's no need to pass through a refinement if we are filtering as we can conveniently use filterMap:

const refined = pipe(inputs, RA.filterMap(transformationOption))

@anthonyjoeseph
Copy link
Contributor Author

anthonyjoeseph commented Apr 5, 2021

馃く this is amazing! Definitely could replace a struct refinement

Is there any reason that apS & bind can't or shouldn't overwrite existing keys? That would eliminate the need for an omit (and maybe even evolve) - it might be more intuitive & ergonomic that way

(Edit: I suppose omit would still be needed for the ergonomic 'delete keys' operation, but it would still be nice to modify values w/o it)

@gcanti
Copy link
Owner

gcanti commented Apr 5, 2021

Here's the same operations using 5 primitives:

import { Endomorphism, pipe } from 'fp-ts/function'
import { Functor1 } from 'fp-ts/Functor'
import { Kind, URIS } from 'fp-ts/HKT'
import * as I from 'fp-ts/Identity'
import * as O from 'fp-ts/Option'

// -------------------------------------------------------------------------------------
// primitives
// -------------------------------------------------------------------------------------

export declare const pick: <S, P extends keyof S>(
  ...props: readonly [P, P, ...ReadonlyArray<P>]
) => (s: S) => Pick<S, P>

export declare const omit: <S, P extends keyof S>(...props: readonly [P, ...ReadonlyArray<P>]) => (s: S) => Omit<S, P>

export declare const insertAt: <S, P extends string, B>(
  prop: Exclude<P, keyof S>,
  value: B
) => (
  s: S
) => {
  readonly [K in keyof S | P]: K extends keyof S ? S[K] : B
}

export declare const rename: <S, F extends keyof S, T extends string>(
  from: F,
  to: Exclude<T, keyof S>
) => (
  s: S
) => {
  readonly [K in Exclude<keyof S, F> | T]: K extends keyof S ? S[K] : S[F]
}

export declare const mapAtE: <F extends URIS>(
  F: Functor1<F>
) => <S, P extends keyof S, B>(
  prop: P,
  f: (sp: S[P]) => Kind<F, B>
) => (
  s: S
) => Kind<
  F,
  {
    readonly [K in keyof S]: K extends P ? B : S[K]
  }
>

// -------------------------------------------------------------------------------------
// utils
// -------------------------------------------------------------------------------------

// derived from `mapAtE`
export declare const modifyAt: <S, P extends keyof S>(
  prop: Exclude<P, keyof S>,
  f: Endomorphism<S[P]>
) => Endomorphism<S>

// derived from `modifyAt`
export declare const updateAt: <S, P extends keyof S>(prop: Exclude<P, keyof S>, ap: S[P]) => Endomorphism<S>

// -------------------------------------------------------------------------------------
// derived
// -------------------------------------------------------------------------------------

const mapAt = mapAtE(I.Functor)

const mapAtO = mapAtE(O.Functor)

// -------------------------------------------------------------------------------------
// tests
// -------------------------------------------------------------------------------------

type Input = {
  readonly a: string | number
  readonly b: number
  readonly c: boolean
}

export const transformationIdentity2 = (i: Input) =>
  pipe(
    i,
    rename('b', 'bb'),
    insertAt('d', new Date(i.b)),
    mapAt('a', (a) => String(a))
  )

export const transformationOption2 = (i: Input) =>
  pipe(
    i,
    mapAtO('a', (a) => (typeof a === 'string' ? O.some(a) : O.none))
  )

@anthonyjoeseph
Copy link
Contributor Author

anthonyjoeseph commented Apr 5, 2021

I think I understand - mapAtE is supposed to be a way to overwrite keys instead of changing bind & apS - is that right? (Edit: as well as a replacement for evolve?)

rename is great - I hadn't thought of that!

Is there a difference between insertAt and Identity.apS?

@gcanti
Copy link
Owner

gcanti commented Apr 5, 2021

If I'm not wrong (but please double check) pick + omit + insertAt + rename + mapAtE should have the same expressive power of the "universal pattern" described above but with more palatable names.

Operation Key types Value types primitive ergonomic name / API
insert keys Changed Fixed Identity.apS insertAt
delete keys Changed Fixed pick, omit pick, omit
rename keys Changed Fixed rename rename
map values Fixed Changed mapAtE mapAt
modify / update values Fixed Fixed mapAtE modifyAt, updateAt

Is there a difference between insertAt and Identity.apS?

No, it's just an alias:

declare const insertAt: <P extends string, S, B>(
  prop: Exclude<P, keyof S>,
  value: B
) => (
  s: S
) => {
  readonly [K in keyof S | P]: K extends keyof S ? S[K] : B
}

// excerpted from `Identity.ts`
declare const apS: <N extends string, A, B>(
  name: Exclude<N, keyof A>,
  fb: B
) => (fa: A) => { readonly [K in N | keyof A]: K extends keyof A ? A[K] : B }

@anthonyjoeseph
Copy link
Contributor Author

anthonyjoeseph commented Apr 5, 2021

That looks like it covers everything to me!

I don't understand the use of Identity.apS if insertAt exists - is there some case where apS would be more appropriate? (Edit: Oh I think I understand - we would just keep apS for completeness's sake)

Out of curiosity, what does the E in mapAtE stand for? evolve?

If I understand correctly, mapAtE would go in Functor.ts, mapAt would go in all the functor instances, and the ergonomic functions would go in struct.ts - does that sound right? Would you consider a PR if I made one for all that? Or should it be in fp-ts-contrib? (Edit - updated this paragraph)

@anthonyjoeseph
Copy link
Contributor Author

anthonyjoeseph commented Apr 5, 2021

Ofc this is very subjective, but what do you think of these names for the ergonomic functions instead? They're a bit shorter, and might be a bit more familiar:

Operation Name
insert keys add
modify values modify (& mapAt = modifyW ?) (from monocle-ts) (lodash calls this update)
update values set (& setW?) (from monocle & lodash)

@gcanti
Copy link
Owner

gcanti commented Apr 6, 2021

@anthonyjoeseph I'd get rid of evolve and add:

  • pick
  • omit
  • insertAt
  • renameAt
  • modifyAt
  • updateAt
  • mapAt

For what concerns mapAtE I'm not sure actually, looks something struct-related so I would add it to the struct module as well.

Out of curiosity, what does the E in mapAtE stand for?

Effect (another example: filterE i.e. a filter with effect)

what do you think of these names...

insertAt, modifyAt, updateAt would be consistent with the corresponding functions in:

  • Array
  • Map
  • NonEmptyArray
  • ReadonlyArray
  • ReadonlyMap
  • ReadonlyNonEmptyArray
  • ReadonlyRecord
  • Record

@qlonik
Copy link
Contributor

qlonik commented Apr 6, 2021

Is there any way to work with structs that we don't quite know the shape of yet (generic)? For example, if I want to insert extra props into a structure for react component:

import { flow, pipe } from 'fp-ts/function';
import * as I from 'fp-ts/Identity';
import { useState } from 'react';

export declare const insertAt: <S, P extends string, B>(
  prop: Exclude<P, keyof S>,
  value: B
) => (
  s: S
) => {
  readonly [K in keyof S | P]: K extends keyof S ? S[K] : B;
};

const x = <P extends Record<string, unknown>>(props: P) =>
  pipe(props, insertAt('state', useState('hello')));

export const ComponentX = (props: { a: string; b: number }) =>
  pipe(props, x, ({ a, b, state: [state, setState] }) => (
    <div>
      {a} {b}
      <button onClick={() => setState('clicked')}>{state}</button>
    </div>
  ));

const y = <P extends Record<string, unknown>>(props: P) =>
  pipe(
    props,
    I.bind('state', () => useState(123))
  );

export const ComponentY = (props: { a: string; b: number }) =>
  pipe(props, y, ({ a, b, state: [state, setState] }) => (
    <div>
      {a} {b}
      <button onClick={() => setState(123)}>{state}</button>
    </div>
  ));

const z = flow(
  I.bind('first', () => useState('hello')),
  I.bind('second', () => useState(123))
);

export const ComponentZ = (props: { a: string; b: number }) =>
  pipe(props, z, ({ a, b }) => (
    <div>
      {a} {b}
    </div>
  ));

Interestingly the usage of functions x and y work out and types are correct, but I cannot correctly type usage of insertAt and I.bind inside x and y. Finally, usage of I.bind inside flow is just broken and creates the function (x: unknown) => ...

@cdimitroulas
Copy link
Contributor

I think in order for I.bind to be correctly inferred inside flow you can use I.Do first e.g.

const z = flow(
  I.Do,
  I.bind('first', () => useState('hello')),
  I.bind('second', () => useState(123))
);

@anthonyjoeseph
Copy link
Contributor Author

anthonyjoeseph commented Apr 6, 2021

@cdimitroulas I think the trouble there is that const z is no longer a function in your above example

@qlonik I think we could use a similar trick we use for prop - here's a rough draft. I'll do something like this for the PR

As for changing bind, it sounds like we have a few ideas:

  • allowing for polymorphism
  • using EnsureLiteral
  • allowing for overwriting existing keys

Not sure if any of those are in scope here. Feel free to open that stuff is as a separate issue if you'd like

@cdimitroulas
Copy link
Contributor

@cdimitroulas I think the trouble there is that const z is no longer a function in your above example

Oops, my bad. Serves me right for replying without testing my code 馃槀

@anthonyjoeseph
Copy link
Contributor Author

Haha no worries, I can definitely relate!

@anthonyjoeseph
Copy link
Contributor Author

anthonyjoeseph commented Apr 7, 2021

@gcanti I'm struggling a bit to understand the use case for mapAtE - is there a reason it creates a function from A => HKT instead of a function from HKT => HKT (like filterE does)? It seems like the latter might be more useful, since you could do several in a row. I'm picturing something like this, similar to transformationOption from your earlier comment:

export declare const mapAtE = <M extends URIS>(M: Chain1<M>) => 
  <A, P extends keyof A, B>(prop: P, f: (ap: A[P]) => Kind<M, B>) => 
  (a: Kind<M, A>): Kind<M, { readonly [K in keyof A]: K extends P ? B : A[K] }>

const mapAtO = mapAtE(O.Chain)

// you can map several keys on the same object
const a: O.Option<{ a: "abc"; b: 123 }> = pipe(
  {a: 'abc', b: 123},
  O.some,
  mapAtO('a', (a) => a === 'abc' ? O.some(a as 'abc') : O.none),
  mapAtO('b', (b) => b === 123 ? O.some(b as 123) : O.none)
)

@gcanti
Copy link
Owner

gcanti commented Apr 7, 2021

@anthonyjoeseph It's usual practice to use the weakest constraint possible, if the atomic operation is "modify a (single) key", Functor is enough.

If you need to modify multiple keys you can use a Chain instance as you ordinarily do in other situations:

const mapAtO = mapAtE(O.Functor)

export const a: O.Option<{ readonly a: 'abc'; readonly b: 123 }> = pipe(
  { a: 'abc', b: 123 },
  mapAtO('a', (a) => (a === 'abc' ? O.some(a as 'abc') : O.none)),

  //  v-- require a `Chain` instance only if you really need it
  O.chain(mapAtO('b', (b) => (b === 123 ? O.some(b as 123) : O.none)))
)

@gcanti
Copy link
Owner

gcanti commented Apr 7, 2021

Since there's an overlap with functional optics, while we're at it I would like come up with consistent APIs:

struct.ts API Lens<S, A> API action
missing get add prop to fp-ts?
pick props rename props to pick in monocle-ts
omit missing add omit to ``monocle-ts`
missing modifyF rename modifyF to modifyE in monocle-ts and add modifyAtE to struct.ts
modifyAt modify
updateAt set
renameAt missing add rename to monocle-ts

For what concerns insertAt and mapAtE I'm investigating.

@gcanti
Copy link
Owner

gcanti commented Apr 21, 2021

For what concerns insertAt and mapAtE I'm investigating

Follow up:

  • insertAt
  • renameAt
  • pick
  • omit

would be used in monocle-ts to implement related features

  • mapAtE (or modifyAtE still not sure about the name..)
  • modifyAt
  • updateAt

would be specific to the struct module only

@anthonyjoeseph
Copy link
Contributor Author

That looks great! Just double checking - insert should be added to Iso.ts, right?

Also, I hate to ask for something so menial, but I'm having a bit of trouble implementing the Kind type signatures for mapAtE in the PR for this issue. The normal way of doing overloaded Kind signatures doesn't seem to work, I think the problem might have something to do with the keyof in the type signature. What I have works but it's a bit ugly. Would anyone be able to help out with that?

@anthonyjoeseph
Copy link
Contributor Author

anthonyjoeseph commented Aug 1, 2021

I'm working on another possible solution to this - a fluent monocle-ts facade. (edit: it's on npm now!) It looks like this:

import { set } from 'spectacles-ts'

const beenSet: { a: { b: number } } = pipe(
  { a: { b: 123 } },
  set(['a', 'b'], -123)
)

Here's the repo - it's called spectacles-ts

Maybe this kind of convenience code can live in that repo instead?

P.S. To be clear - this is meant as a supplement to monocle-ts, not to replace it. It can only do around 5 operations before reaching the type instantiation limit

(edit: updated repository name)
(edit 2: updated name again & released!)

@kalda341
Copy link

While we're talking about the way struct functions behave, I have a gripe with the existing evolve with regards to partial structs. Here's a simplified example of the problem:

interface A {
  a?: 'a';
  b?: 'b';
  c?: 'c';
}

const a: A = {};
const b = F.pipe(
  a,
  evolve({
    a: fromNullable,
    b: fromNullable,
    c: fromNullable,
  })
);

The type of b is not partial, however the actual value of b is {}.
How is this best addressed?

@anthonyjoeseph
Copy link
Contributor Author

@kalda341 great catch! I've submitted a separate issue for this bug

@anthonyjoeseph
Copy link
Contributor Author

closing in favor of gcanti/monocle-ts#183

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

Successfully merging a pull request may close this issue.

5 participants