gvergnaud / hotscript

A library of composable functions for the type-level! Transform your TypeScript types in any way you want using functions you already know.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

[Proposal] New encoding for Functions

gvergnaud opened this issue Β· comments

Motivation

Our current function encoding creates a dichotomy between arguments received through Apply (this["args"]) and arguments passed explicitly via type parameters. This means every function needs to manually merge these two types of inputs, which is cumbersome and error prone.

In addition, we have a weird distinction between our "pipeable functions" that can't be called directly but only through Call, Apply or Eval, and regular generic types that can be called directly, but aren't "pipeable".

Proposed Solution

Here is what I think could be a good solution:

  • Fn extensions always receive their argument through Apply, and read them using a new Fn.args<this> function.
  • Wrap our Fn extensions in type aliases that support partial inputs by using a new Curry function.
  • (mostly unrelated to this problem) rename output into return, to stay close to the semantics of a value-level function.

Pros:

  • We can partially apply a function or give it all of its arguments using the same api: MyFn<A, B, C>
  • We no longer have to manually merge our different types of arguments
  • Fn.args<this> is easier to type than this["args"]
  • Fn.arg0<this> is easier to type than this["args"][0]
/**
 * Core
 */
export interface Fn {
  args: unknown
  return: unknown;
}

export type Apply<fn extends Fn, args extends unknown[]> = (fn & { args: args })["return"];

namespace Fn {
  export type args<F> =
    F extends { args: infer args extends unknown[] } ? RemoveUnknownArrayConstraint<args> : never;

  export type arg0<F> =
    F extends { args: [infer arg, ...any] } ? arg : never;

  export type arg1<F> =
    F extends { args: [any, infer arg, ...any] } ? arg : never

  export type arg2<F> =
    F extends { args: [any, any, infer arg, ...any] } ? arg : never

  export type arg3<F> =
    F extends { args: [any, any, any, infer arg, ...any] } ? arg : never
}


export type Pipe<acc, xs extends Fn[]> = xs extends [
  infer first extends Fn,
  ...infer rest extends Fn[]
]
  ? Pipe<Apply<first, [acc]>, rest>
  : acc;

export type placeholder = "@hotscript/placeholder";
export type unset = "@hotscript/unset";



export interface CurryImpl<fn extends Fn, partialArgs extends unknown[]>
  extends Fn {
  return: MergeArgs<Fn.args<this>, partialArgs> extends infer args extends unknown[]
    ? Apply<fn, args>
    : never
}

type Curry<fn extends Fn, partialArgs extends unknown[]> =
  unset extends partialArgs[number]
  ? CurryImpl<fn, partialArgs>
  : placeholder extends partialArgs[number]
  ? CurryImpl<fn, partialArgs>
  : Apply<fn, partialArgs>

/**
 * Custom function
 */

/**
 * Parameters are assigned with Fn.args, Fn.arg0, Fn.arg1, Fn.arg2, etc.
 */
interface ExcludeNumberFn extends Fn {
  return: Fn.arg0<this> extends infer T ? T extends number ? never : T : never
  //                                      πŸ‘†
  //                     Since we assigned T, this becomes
  //                     A naked type variable, which means
  //                     This expression distributes.
  //
  //                     It wouldn't with the simpler: 
  //                     `Fn.arg0<this> extends number ? never : Fn.arg0<this>`.
}

/**
 * Wrap this in a call with curry:
 */
type ExcludeNumber<T = unset> = Curry<ExcludeNumberFn, [T]>

/**
 * We can use it directly:
 */
type exclude1 = ExcludeNumber<"Gabriel" | 1 | 2 | "hello">
//    ^? "Gabriel" | "hello"

/**
 * Or through Apply!
 */
type exclude2 = Apply<ExcludeNumber, ["Gabriel" | 1 | 2 | "hello"]>
//    ^? "Gabriel" | "hello"


/**
 * It becomes more interesting with several parameters:
 */
interface PrependFn extends Fn {
  // you don't have to merge piped args with partial args manually:
  return: Fn.args<this> extends [infer Sep extends string, infer Str extends string]
    ? `${Sep}${Str}`
    : never
}

// Because this is done by `Curry`
type Prepend<Sep = unset, Str = unset> = Curry<PrependFn, [Sep, Str]>

// You can partially apply and pipe:
type re3 = Pipe<"Hello", [
  //  ^? "start_start_start_Hello"
  Prepend<"start_">,
  Prepend<"start_">,
  Prepend<"start_">,
]>

// You can use it directly:
type res2 = Prepend<"start", "end">
//    ^? "startend"

// Here is what you get if you partially applied:
type res4 = Prepend<"start">
//    ^? CurryImpl<PrependFn, ["start", "@hotscript/unset"]>

// And here what you get when you "pipe":
type res5 = Apply<Prepend<"start">, ["end"]>
//    ^? "startend"


/**
 * With this new encoding, we can also get rid of some weirdness 
 * We have with function of arity 2 like `Extends` when partially applied.
 */

// This is what you would expect:
export interface ExtendsFn extends Fn {
  return: [Fn.arg0<this>] extends [Fn.arg1<this>] ? true : false
}

// and we handle the swapping of arguments here instead: 
type Extends<A = unset, B = unset> =
  Curry<ExtendsFn, B extends unset ? [unset, A] : [A, B]>
  //                                       πŸ‘†
  //                              Swap if B is unset

type res6 = Extends<2, number>
//   ^? true

type res7 = Pipe<"hello", [Extends<string>]>
  //   ^? true
  // The arguments are swapped here

πŸ‘‰ Playground

Other explored solutions

Use a function type to assign parameters and get the output

I also tried this approach of using a function type on Fn to both assign arguments and compute the output:

export interface Fn {
  args: unknown
  fn(): unknown;
}

export type Apply<fn extends Fn, args extends unknown[]> =
    ReturnType<(fn & { args: args })["fn"]>;
//      πŸ‘†
//   We need to get the return type

export interface ExtendsImpl extends Fn {
  fn<A extends Fn.arg0<this>, B extends Fn.arg1<this>>():
  // πŸ‘† args are declared like this
    [A] extends [B] ? true : false
  // πŸ‘† and this is the body of our function
}

// This stays the same
type Extends<A = unset, B = unset> =
  Curry<ExtendsImpl, B extends unset ? [unset, A] : [A, B]>

type res6 = Extends<2, number>
//   ^? true

type res7 = Pipe<"hello", [Extends<string>]>
  //   ^? true

pros:

  • The definition of arguments and return types of our function looks familiar
    cons:
  • It means TS will run ReturnType<...> a lot, which could be bad for perf

πŸ‘‰ playground

commented

@gvergnaud found at that using currying breaks composition. But improvement on PartialApplication is still valid.
So this part was merged.

Closing.