Skip to content
This repository has been archived by the owner on Feb 16, 2021. It is now read-only.

suggestion: dynamic function type #8

Closed
amir-arad opened this issue Jul 26, 2017 · 29 comments
Closed

suggestion: dynamic function type #8

amir-arad opened this issue Jul 26, 2017 · 29 comments

Comments

@amir-arad
Copy link

amir-arad commented Jul 26, 2017

Following gcanti/fp-ts#177,
It would be great if we had a variadic generic function type before microsoft/TypeScript#5453 is resolved.

My best shot was this:
(I've opened a separate PR #7 for NumberToString as it seems useful on its own)

export type NumberToString = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10'];
export type Func<I extends number, A, R=void> = {
    0: ()=>R
    1: (a0:A[0]) => R
    2: (a0:A[0], a1:A[1]) => R
    3: (a0:A[0], a1:A[1], a2:A[2]) => R
    4: (a0:A[0], a1:A[1], a2:A[2], a3:A[3]) => R
    5: (a0:A[0], a1:A[1], a2:A[2], a3:A[3], a4:A[4]) => R
    6: (a0:A[0], a1:A[1], a2:A[2], a3:A[3], a4:A[4], a5:A[5]) => R
    7: (a0:A[0], a1:A[1], a2:A[2], a3:A[3], a4:A[4], a5:A[5], a6:A[6]) => R
    8: (a0:A[0], a1:A[1], a2:A[2], a3:A[3], a4:A[4], a5:A[5], a6:A[6], a7:A[7]) => R
    9: (a0:A[0], a1:A[1], a2:A[2], a3:A[3], a4:A[4], a5:A[5], a6:A[6], a7:A[7], a8:A[8]) => R
    10: (a0:A[0], a1:A[1], a2:A[2], a3:A[3], a4:A[4], a5:A[5], a6:A[6], a7:A[7], a8:A[8], a9:A[9]) => R
}[NumberToString[I]];

usage example:

const stringToNumber: Func<1, [string], number> = Number.parseInt; //works
const numberToNumber: Func<1, [number], number> = Number.parseInt; // breaks

However the first generic param, I seems redundant though I couldn't shake it off. I'm beginning to use this type in my own project, and I would welcome any improvement ideas.

@SimonMeskens

@gcanti
Copy link
Owner

gcanti commented Jul 26, 2017

Maybe through hlists?

export type THListToFunction<L extends THList, C> = {
  true: () => C
  false: {
    true: (a: THListHead<L>) => C
    false: {
      true: (a: THListHead<L>, b: THListHead<THListTail<L>>) => C
      false: {
        true: (a: THListHead<L>, b: THListHead<THListTail<L>>, c: THListHead<THListTail<THListTail<L>>>) => C
        false: 'error' // TODO more cases
      }[IsZero<THListLength<THListTail<THListTail<THListTail<L>>>>>]
    }[IsZero<THListLength<THListTail<THListTail<L>>>>]
  }[IsZero<THListLength<THListTail<L>>>]
}[IsZero<THListLength<L>>]

export type THListToCurriedFunction<L extends THList, C> = {
  true: () => C
  false: {
    true: (a: THListHead<L>) => C
    false: {
      true: (a: THListHead<L>) => (b: THListHead<THListTail<L>>) => C
      false: {
        true: (a: THListHead<L>) => (b: THListHead<THListTail<L>>) => (c: THListHead<THListTail<THListTail<L>>>) => C
        false: 'error' // TODO more cases
      }[IsZero<THListLength<THListTail<THListTail<THListTail<L>>>>>]
    }[IsZero<THListLength<THListTail<THListTail<L>>>>]
  }[IsZero<THListLength<THListTail<L>>>]
}[IsZero<THListLength<L>>]

Usage

import { THListToFunction, TupleLength, THListReverse, THListToCurriedFunction, TupleToTHList } from 'typelevel-ts'

type L = THListReverse<TupleToTHList<[number, string, boolean]>>
// type F = (a: number, b: string, c: boolean) => void
type F = THListToFunction<L, void>
// type C = (a: number) => (b: string) => (c: boolean) => void
type C = THListToCurriedFunction<L, void>

@amir-arad
Copy link
Author

amir-arad commented Jul 26, 2017

in a nutshell: improvement, but still...

basically same problems as with objects and tuples, I can't get the naive usage example to work.
there's a problem with recursive types (in this case TupleToTHList), where the counter has a default value, it can't be used indirectly via another type.
in this example (hlists), I'd like something like this:

export type Func<A, R=void> = THListToFunction<THListReverse<TupleToTHList<A>>, R>

so that I can use it like this:

declare function parseInt(string: string, radix?: number): number;
const stringToNumber: Func<[string], number> = parseInt;
// $ExpectError Type 'number' is not assignable to type 'string'
const numberToNumber: Func<[number], number> = parseInt;

The closest I got was:

export type Func<L extends THList, R=void> = THListToFunction<THListReverse<L>, R>

and I can use it like this:

declare function parseInt(string: string, radix?: number): number;
const stringToNumber: Func<TupleToTHList<[string]>, number> = parseInt;
// $ExpectError Type 'number' is not assignable to type 'string'
const numberToNumber: Func<TupleToTHList<[number]>, number> = parseInt;

I guess replacing the length withTupleToTHList is an improvement. I did however copy TupleToTHList as Args so that I can minimize the API clutter:

declare function parseInt(string: string, radix?: number): number;
const stringToNumber: Func<Args<[string]>, number> = parseInt;
// $ExpectError Type 'number' is not assignable to type 'string'
const numberToNumber: Func<Args<[number]>, number> = parseInt;

@amir-arad
Copy link
Author

YES

export type Func<T, R=void, I = 0, L = THNil> = {
    true: Func<T, R, Increment[I], THCons<T[I], L>>;
    false: THListToFunction<THListReverse<L>, R>;
}[ObjectHasKey<T, I>];

@gcanti
Copy link
Owner

gcanti commented Jul 26, 2017

@amir-arad nice!

@amir-arad
Copy link
Author

yeah... this is nice.
but, it doesn't scale. I can't use it to define APIs, it can't be used inside other types.
:(

@amir-arad
Copy link
Author

amir-arad commented Jul 26, 2017

the problem is with the way ObjectHasKey responds to tuples when I = 0 is involved. it has to be called from the first type in the stack or it reverts to false. I'm back at asking the user to use Args<[string]> instead of [string]

@SimonMeskens
Copy link

I would get rid of the I=0 through something like this, but it doesn't seem to work:

export type Func<T, R=void, L = THNil> = {
    true: Func2<T, Increment[0], R, THCons<T[0], L>>;
    false: THListToFunction<THListReverse<L>, R>;
}[ObjectHasKey<T, 0>];

export type Func2<T, I, R=void, L = THNil> = {
    true: Func2<T, Increment[I], R, THCons<T[I], L>>;
    false: THListToFunction<THListReverse<L>, R>;
}[ObjectHasKey<T, I>];

@SimonMeskens
Copy link

Does TupleLength work for you guys? It doesn't seem to work for me.

@SimonMeskens
Copy link

I think we're running into a compiler bug, where ObjectHasKey doesn't work on generic type parameters when used in an indexer or something like that.

@SimonMeskens
Copy link

@gcnew Do you happen to know why this code fails?

type A<T> = {
  true: 'true'
  false: 'false'
}[ObjectHasKey<T, '0'>]

type B = ObjectHasKey<[string], '0'> // B = "true"
type C = A<[string]> // C = "false"

Do you think your PR fixes this one?

@gcnew
Copy link

gcnew commented Jul 26, 2017

Unfortunately no, I don't know what's going wrong and my PR doesn't fix it either. I'll try to take a second look later tonight.

@gcnew
Copy link

gcnew commented Jul 26, 2017

In type A<T> = ..., ObjectHasKey<T, '0'> gets simplified to just "false" (you can observe that behaviour by changing the catch-all index signature to e.g. { [key: string]: 'lie' }). The rule that gets applied here is:

  • Given a type parameter T with a constraint that includes a string index signature of type X, the apparent type of an indexed access type T[K] is X.

I'm not sure why this substitution is only selectively applied, though.

@gcnew
Copy link

gcnew commented Jul 26, 2017

To be honest StringContains is a bit hacky. When the key exists, the actual type should be 'true' & 'false' and when it doesn't - just 'false'. I guess it appears to work most of the time and returns naked "true" due to optimisations based on

  • The operation { [P in K]: T}[X] is equivalent to an instantiation of T where X is substituted for every occurrence of P. For example, { [P in K]: Box<T[P]> }[X] is equivalent to Box<T[X]>.

@SimonMeskens
Copy link

A very large part of the useful abstractions in TypeScript end up being hacky mapped types. Perhaps we should just petition the TypeScript team to formalize a few fundamental ones into proper features of the language.

As for the topic of the issue, I feel like the feature is actually not that hard to add, the issue is just that some of the needed constructs are too hacky. I'll have a look to see if we can find alternate implementations that perform better.

@amir-arad
Copy link
Author

@gcnew where are you quoting from? this book?

@gcnew
Copy link

gcnew commented Jul 27, 2017

From microsoft/TypeScript#12351. I've not been completely right, though. The real cause is a bit different. Mapped types inside intersections are not recognised as such and the resulting type is eagerly computed. I've been experimenting in a branch and the above code works there as expected. However, indexing errors caused by wrong index signatures are not detected properly.

E.g. the following passes unnoticed:

type StringContains<S extends string, L extends string> = ({ [K in S]: 'true' } & {
  [key: string]: 'lie' // notice `lie` here
})[L]

type ObjectHasKey<O, L extends string> = StringContains<keyof O, L>


type A1<T> = {
  true: 'true'
  false: 'false'
}[ObjectHasKey<T, '1'>] // no error, but `lie` is invalid

@gcnew
Copy link

gcnew commented Jul 27, 2017

I've opened microsoft/TypeScript#17456 describing the intersection indexing issue.

@amir-arad
Copy link
Author

amir-arad commented Jul 30, 2017

gladly microsoft/TypeScript#17456 is scheduled to typescript 2.5
when it's out I'll use it to prettify my function types, add support for bind, call, etc. and submit a PR, or even a standalone library for functional API typings.

@SimonMeskens
Copy link

Unfortunately, even with these fixes and the new spread/rest types landing in 2.5, I think it still won't give us proper support for currying, bind, call, etc. The issue is that if the function in question has generics, I don't see how any feature could ever make that work correctly. What we will get is a solution that solves most of the use cases, but not all

Here's a list of issues that are relevant to this issue:
microsoft/TypeScript#17455
microsoft/TypeScript#17456
microsoft/TypeScript#10727

All of these are marked for 2.5 release

@KiaraGrouwstra
Copy link

KiaraGrouwstra commented Aug 1, 2017

Given a type parameter T with a constraint that includes a string index signature of type X, the apparent type of an indexed access type T[K] is X.

So based on whether we write T or T & {} we can decide whether we'd like it to resolve a property access normally or to instead prioritize the index ignoring actual keys?

Ironically, I'd actually consider that a feature, in the sense it'd solve another issue of otherwise not having a way to access the index type when using property access to a key part of object prototype, e.g. toString.
Solving that would otherwise seem to need 6606 to check for the presence of the index and branch based on that.

The good thing is we can mostly control whether we get intersection types (using that and Pick) I guess? Though it's probably a bit more complicated with generics and evaluation order; we'd have to like only pass things in once we've already processed this part...

^ edit: oh, guess Pick isn't solving our problem here, as it kills the string index rather than merging it in... In fact, it doesn't appear possible to have the string index in the same object and having the values of individual keys not sub-type that.

I think it still won't give us proper support for currying, bind, call, etc.

I tried a curry PoC in 5453 given that and 6606 that properly handles dynamic return types.

bind though... if I correctly understand that the (this: Foo, par2: Bar) => Baz syntax has no actual magic on the this keyword, then I think handling this type-wise brings a problem -- this on the expression level appears fully disconnected from this type-level approach.

Given that, I don't really see immediate solutions there that would actually be faithful to its expression-level functionality in providing a value for the this 'parameter' unless that would somehow automatically exposed on the original function type...

For this subjects using generic types this should definitely affect the return type though, so it is technically useful in calculating accurate return types.
But yeah, with inspiration from allegedly Java, I guess it's not a complete surprise that TS appears to have just skipped over JS's this semantics (-> function binding) not actually matching traditional OOP this.
Then again, I'm not sure which use-cases would suffer most from this.

I don't see how any feature could ever make that work correctly. What we will get is a solution that solves most of the use cases, but not all

Given 6606 you would be able to dynamically calculate the return types for given inputs and generics. Do you see further problems aside from the this-based ones we don't know how to solve yet?

even with these fixes and the new spread/rest types landing in 2.5

Given object spread/rest are just sugar to Omit / Overwrite types, they don't add anything for the type level, just allow putting a type signature on destructuring statements from what I can see.

To be honest StringContains is a bit hacky. When the key exists, the actual type should be 'true' & 'false'

That'd suck pretty hard, I'm glad the index functions as a fallback instead of like that :), can't really do anything useful much with such intersections...

@amir-arad
Copy link
Author

amir-arad commented Aug 1, 2017

regarding bind, curry etc.
My plan is to create 11 generic function types with exact number of arguments each, with typed bind signature etc. I may be missing something but I don't think this should be especially hard. the trick will be getting the right function by means of a generic signature, and I think I have a reasonable POC for doing that, though I dont like the way it looks today (that's why I'm waiting for microsoft/TypeScript#17456 ).
I'd appreciate suggestions and corrections, as I'm not always sure I decypher the tech terms correctly. I'm still learning the ropes.

@KiaraGrouwstra
Copy link

To explain, what this approach can't address is return types that depend on the input (6606), or, worse, depend on generics of this (no known proposal fixes this). It's nitpicking, yeah.

On-topic, 17456 then this?

export type Func<A extends any[], R=void> = {
    0: ()=>R
    1: (a0:A[0]) => R
    2: (a0: A[0], a1: A[1]) => R
    // ...
}[TupleLength<A>];

const stringToNumber: Func<[string], number> = Number.parseInt; //works
const numberToNumber: Func<[number], number> = Number.parseInt; // breaks

@amir-arad
Copy link
Author

could you explain what you mean by

17456 then this

?

@KiaraGrouwstra
Copy link

@amir-arad: this snippet also wouldn't work yet, since as you guys noted, TupleLength, through ObjectHasKey, suffers from the bug gcnew filed at microsoft/TypeScript#17456.

@amir-arad
Copy link
Author

amir-arad commented Aug 2, 2017

@tycho01
I'd say 17456, then writing better function types than fp-ts such as:

export interface Lazy<R, T=never>{
    (this:T):R;
    bind<T1 extends T>(ctx:T1):BoundLazy<R, T1>; // BoundLazy is similar to lazy only with no bind method or something
    // ...
}

and then a generic Function type:

export type Func<A, R=void, T=never> = { // I dont think A works the same as an array, it's a tuple.
    0: Lazy<R, T>;
    1: Function1<A[0], R, T>;
    2: Function2<A[0], A[1], R, T>;
    // ...
}[NumberToString[TupleLength<A>]];

again, newbie's naive approach, would welcome feedback : )

@KiaraGrouwstra
Copy link

I misjudged; looks like this is not just an ordinary parameter name in TS.

I guess that means that handling function this un-/re-binding at the type level becomes technically possible given either 5453 or 6606 to capture the params.

6606 could simultaneously help make for accurate return types, 5453 would make for better syntax/performance.

If 6606 were expanded on to allow specifying this-params as well (e.g. random syntax idea Fn(this: T, A, B, C)), the return type could even be calculated such as to depend on such a this binding.

Provisional code snippets following those proposals:

// unbind: in TS already automatically happens when it should in JS (getting a method without directly applying it).
export type Unbind<F extends (this: This, ...args: Args) => R, This, Args extends any[], R> = Fn<[This, ...Args], R>;
// ^ can't capture params in generic until maybe #5453, error "A rest parameter must be of an array type."
// ^ #6606 upgrade: don't capture `R`, instead use `F(this: This, ...Args)`
export type Unbind<F extends (this: This, t1: T1) => R, This, R, T1> = Fn<[This, T1], R>;
export type Unbind<F extends (this: This, t1: T1, t2: T2) => R, This, R, T1, T2> = Fn<[This, T1, T2], R>;
export type Unbind<F extends (this: This, t1: T1, t2: T2, t3: T3) => R, This, R, T1, T2, T3> = Fn<[This, T1, T2, T3], R>;
// ...
// ^ wait, discriminating generic `F` type probably needs #6606 overloads
// complication: most stdlib methods don't have the `this` param specified. fix that. wonder why it isn't added implicitly...

export type Bind<F extends (this: This, ...args: Args) => R, This, Args, R, T extends This> = Fn<Args, R>;
// ^ doesn't handle other params yet. `unbind` notes above apply as well.
// ^ accurate return type over `R`: `F(this: T, ...Args)`.

BoundLazy is similar to lazy only with never as the bind argument type

Well, technically you can re-bind. Otherwise, essentially, yeah.

I suppose it'd be nicer to solve bind within lib.d.ts so it'd work consistently. However, today param capturing is still an issue for that I guess (as noted above).

So essentially until we can do that binding, a this-only (-> not passing other params) is just an identity operation. If you'd PR such a this-only bind overload to lib.d.ts (be it just nullary as in Lazy or one per arity), Lazy could then just be Lazy<R> = () => R.

I now see you're addressing bind's thisArg param currently being typed any, by restricting it to the type of... I guess T would be a spec of the class (e.g. Array<any>) rather than point to the originally bound object?'

You're definitely adding something that Function.prototype methods have currently been failing at. It'd be nice if this were improved such that methods on a prototype would automatically pass what prototype class they're coming from, e.g. Function -> Function<Cls> with thisArg: Cls, automatically yielding e.g. Array<any> on the Array<T> prototype, for one. I'm not currently aware of outstanding proposals in that direction.

@amir-arad
Copy link
Author

amir-arad commented Aug 2, 2017

While trying to give a good use case for advanced this argument typing I found out I don't have one that does not rely on features not yet in the language and has a clear value-for-typing ratio.
I guess I'll just start with better expressing the ordinary function arguments, and see what value I can get there. the this argument can come after I gain some XP
Anyway, I'm more comfortable with hacking what I can from the existing language than with participating in the proposals forum.

@KiaraGrouwstra
Copy link

With that compiler issue solved, I'd have expected TupleLength to be fixed now, and I'm getting it to pass its tests. Its actual use-cases mostly still fail for me though, most of which somehow just end up not terminating anymore in the nightlies, including that Func. Hm.

@KiaraGrouwstra
Copy link

I just retried on recent TS, and got tests for this Fn type to work.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants