gcanti / fp-ts

Functional programming in TypeScript
https://gcanti.github.io/fp-ts/
MIT License
10.63k stars 503 forks source link

[Question (feature?)] Clean way to define a Semigroup over a (partially) partial type #1902

Open silasdavis opened 10 months ago

silasdavis commented 10 months ago

Forgive my abuse of the issue types, I'm not sure if this is a feature request, perhaps there is a clean way to handle it.

I would like to define a custom Semigroup for merging structs. To preserve a sane typescript interface I'd like the input type to my merging function to be:

    type PartialOptions = {
      baseUrl: string;
      token?: string;
      extraHeaders?: Record<string, string>;
      number?: number;
    };

I can define a Semigroup that does what I want by replacing the optional fields with suitable zero values using Option for token.

Below is a solution that first normalise the input values into a type that is easier to work with in fp-ts, however it involves additional boilerplate which is a bit of a shame. Namely:

This ticket looks like it is dealing with just the same flavour of problem: https://github.com/gcanti/fp-ts/issues/1636

Here's a test case demonstrating what I'm thinking of:

describe('fp-ts', () => {
  test('Merging an options type', () => {
    // Helper type for inferring the type implied by a Semigroup
    type SemigroupType<T> = T extends S.Semigroup<infer U> ? U : never;

    // Define a merging semigroup using Option for nullable values
    const OptionsSemigroup = S.struct({
      baseUrl: S.last<string>(),
      token: O.getMonoid(S.last<string>()),
      extraHeaders: R.getUnionSemigroup(S.last<string>()),
      // Just for fun
      number: N.SemigroupSum,
    });

    type Options = SemigroupType<typeof OptionsSemigroup>;

    const optionsMerge = S.concatAll(OptionsSemigroup);

    // We could derive this from Options, but I don't know if a generic way to handle omitted values as zero types.
    // the amount of boilerplate in the current approaches makes this clean
    type PartialOptions = {
      baseUrl: string;
      token?: string;
      extraHeaders?: Record<string, string>;
      number?: number;
    };

    function optionify({ baseUrl, token, extraHeaders = {}, number = 0 }: PartialOptions): Options {
      return {
        baseUrl,
        token: O.fromNullable(token),
        extraHeaders,
        number,
      };
    }

    // Here's what the outside world should see
    function mergeOptions(base: PartialOptions, ...options: PartialOptions[]) {
      return optionsMerge(optionify(base))(options.map(optionify));
    }

    // Here's the example usage
    const base: Options = {
      baseUrl: 'bar',
    };

    const result = mergeOptions(
      base,
      { number: 4, baseUrl: 'fff', extraHeaders: { foo: 'bar', bang: 'boo' } },
      { number: 6, baseUrl: 'fff', token: O.some('asdsadsadasd') },
      { number: 8, baseUrl: 'fff', token: 'oooooo', extraHeaders: { newKey: 'blah' } },
      { number: 10, baseUrl: 'http://example.com', extraHeaders: { foo: 'barbarbar' } },
    );
    console.log(result);
    expect(result).toStrictEqual({
      baseUrl: 'http://example.com',
      token: O.some('oooooo'),
      extraHeaders: { foo: 'barbarbar', bang: 'boo', newKey: 'blah' },
      number: 28,
    });
  });
});

If this is a poor fit for the issues section here then my apologies and I will close it.

silasdavis commented 10 months ago

Well reading the ticket linked above again (https://github.com/gcanti/fp-ts/issues/1636) having tried to solve the problem myself, perhaps this is exactly a dupe of that!

Side-question: not sure why the above compiles, but I am nesting options in my usage.

Is there an existing function idiom for taking (x: undefined | A | Option<A>) => Option<A>?