[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 throughApply
, and read them using a newFn.args<this>
function.- Wrap our
Fn
extensions in type aliases that support partial inputs by using a newCurry
function. - (mostly unrelated to this problem) rename
output
intoreturn
, 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 thanthis["args"]
Fn.arg0<this>
is easier to type thanthis["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
@gvergnaud found at that using currying breaks composition. But improvement on PartialApplication is still valid.
So this part was merged.
Closing.