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

Compose: implement compose natively instead of reexporting from Lodash #32734

Closed
wants to merge 1 commit into from

Conversation

jsnajdr
Copy link
Member

@jsnajdr jsnajdr commented Jun 16, 2021

This could solve the issues with buggy (?) types for Lodash that @sarayourfriend is dealing with in #32709.

Currently just a draft, it needs at least JSDoc and/or TypeScript types before it's ready.

@jsnajdr jsnajdr self-assigned this Jun 16, 2021
@jsnajdr jsnajdr added the [Package] Compose /packages/compose label Jun 16, 2021
@gziolo gziolo added the [Type] Code Quality Issues or PRs that relate to code quality label Jun 18, 2021
@sirreal
Copy link
Member

sirreal commented Jun 21, 2021

I looked into typing this after conversation at #32709. It's an interesting problem 🙂

In my findings, the best solution seems to be a bunch of overrides:

export function compose<A, B, C>( ...fs: [Fn<B, C>, Fn<A, B>] ): (input: A) => C;
export function compose<A, B, C, D>( ...fs: [Fn<C, D>, Fn<B, C>, Fn<A, B>] ): (input: A) => D;
export function compose<A, B, C, D, E>( ...fs: [Fn<D, E>, Fn<C, D>, Fn<B, C>, Fn<A, B>] ): (input: A) => E;
// …as far as you want to go…
export function compose(...fs: [AnyFn, ...AnyFn[], AnyFn]) {
  return (input: FirstArg<typeof fs>): LastReturn<typeof fs> =>
    fs.reduceRight((val, f) => f(val), input);
}

It has good user experience:

Screen Shot 2021-06-21 at 16 55 46

Although type errors are a bit funny with right-to-left application. Here, the first-applied function (2nd param) receives a string and returns a number, while the second-applied (1st param) function accepts a string:

Screen Shot 2021-06-21 at 16 55 13


Dealing with a well-typed arbitrary number of functions did not seem to be possible providing satisfactory user experience (although I'd love to be proved wrong). The closest I was able to get was a never result if the functions aren't coherent, however the type is much more complex and we're probably better off just writing out a bunch of overrides like above.

My submission including work including alternate types
// For testing and comparison
import _ from "lodash";
const { flowRight } = _;
export { flowRight };

// Some testing functions with simple types
export const stringToNumber = (x: string): number => {
  if (typeof x !== "string") {
    throw new TypeError(`Expected a number, got ${JSON.stringify(x)}`);
  }
  return x.length;
};
export const numberToString = (x: number): string => {
  if (typeof x !== "number") {
    throw new TypeError(`Expected a number, got ${JSON.stringify(x)}`);
  }
  return x.toExponential();
};
export const arrayWrap = <T>(x: T): [T] => (console.log([x]), [x]);
export const arrayUnwrap = <T>([x]: [T]): T => (console.log(x), x);

// A couple of utilities
type Fn<A, B> = (arg: A) => B;
type AnyFn = Fn<any, any>;

//
// A big complex implementation for arbitrary number of inputs
//
type NextFn<In, Fs extends [...AnyFn[], Fn<In, any>]> = Fs extends [
  Fn<In, infer Out>
]
  ? Out
  : Fs extends [...infer Rest, Fn<In, infer Out>]
  ? Rest extends [...any, Fn<Out, any>]
    ? NextFn<Out, Rest>
    : never
  : never;

type Compose<Fs extends [AnyFn, ...AnyFn[], AnyFn]> = Fs extends [
  ...infer Rest,
  Fn<infer Input, infer NextIn>
]
  ? Rest extends [...any, Fn<NextIn, any>]
    ? NextFn<NextIn, Rest> extends never
      ? never
      : Fn<Input, NextFn<NextIn, Rest>>
    : never
  : never;

type FirstArg<Fs extends [...AnyFn[], AnyFn]> = Fs extends [
  ...any,
  Fn<infer A, any>
]
  ? A
  : never;

type LastReturn<Fs extends [AnyFn, ...AnyFn[]]> = Fs extends [
  Fn<any, infer A>,
  ...any
]
  ? A
  : never;

export const composeAlt = <Fs extends [AnyFn, ...AnyFn[], AnyFn]>(...fs: Fs) =>
  ((initial: FirstArg<Fs>) =>
    fs.reduceRight((val, f) => f(val), initial)) as Compose<Fs>;

const fAlt = composeAlt(stringToNumber, numberToString, stringToNumber);
const gAlt = composeAlt(stringToNumber, stringToNumber);

//
// A much simpler implementation for a set number of inputs that must be manually typed out.
// I recommend this approach.
//
export function compose<A, B, C>(...fs: [Fn<B, C>, Fn<A, B>]): (input: A) => C;
export function compose<A, B, C, D>(
  ...fs: [Fn<C, D>, Fn<B, C>, Fn<A, B>]
): (input: A) => D;
export function compose<A, B, C, D, E>(
  ...fs: [Fn<D, E>, Fn<C, D>, Fn<B, C>, Fn<A, B>]
): (input: A) => E;
// …as far as you want to go…
export function compose(...fs: [AnyFn, ...AnyFn[], AnyFn]) {
  return (input: FirstArg<typeof fs>): LastReturn<typeof fs> =>
    fs.reduceRight((val, f) => f(val), input);
}


const f = compose(stringToNumber, numberToString, stringToNumber);
const g = compose(stringToNumber, stringToNumber);

@Mamaduka
Copy link
Member

@tyxla, this should be resolved by #44112, correct?

Copy link
Member

@tyxla tyxla left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch @Mamaduka, I agree - this can be closed in favor of #44112.

@jsnajdr
Copy link
Member Author

jsnajdr commented May 18, 2023

@tyxla's version in #44112 has only very naive types, where every composed function has just type Function and arguments are unknown[]. Sometime we could improve that, but it will be another PR. Let's close this one.

@jsnajdr jsnajdr closed this May 18, 2023
@sirreal sirreal deleted the add/native-compose branch June 24, 2024 10:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Package] Compose /packages/compose [Type] Code Quality Issues or PRs that relate to code quality
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants