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

Introduce mechanism for manipulating function types without losing generic type information #50481

Open
5 tasks done
TheUnlocked opened this issue Aug 27, 2022 · 5 comments
Open
5 tasks done
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@TheUnlocked
Copy link

TheUnlocked commented Aug 27, 2022

Suggestion

πŸ” Search Terms

  • generic function add parameter
  • parameters of generic function
  • prepend parameter to generic function

βœ… Viability Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

Being able to manipulate generic function types without losing the generic type parameters would be very useful (see under motivating example). Coming up with a good syntax for it is not easy, but I see a few possible approaches (precise syntax/names subject to change):

  1. A bunch of utility types (inelegant but gets the job done)
type F = <T, R>(t: T) => R;

type X1 = WithReturnType<F, number>; // X1 = <T, R>(t: T) => number;
type X2 = WithParameters<F, [x: number]>; // X1 = <T, R>(x: number) => R;
type X3 = PrependParameter<F, number>; // X1 = <T, R>(arg0: number, t: T) => R;
type X4 = AppendParameter<F, number>; // X1 = <T, R>(t: T, arg0: number) => R;
type X5 = ReplaceParameter<F, 0, number>; // X1 = <T, R>(t: number) => R;
  1. Enhancement of extends as is (probably the prettiest solution but may present challenges when speccing it out):
type F = <T, R>(t: T) => R;

type X1 = F extends (...args: infer Args) => infer R
    ? (x: number, ...args: Args) => R
    : never;
// ...
  1. Enhancement of extends with new syntax to declare intent (this is much more general but would only make sense when combined with a broader "generic generics" or HKT feature):
type F = <T, R>(t: T) => R;

type X1 = F extends <...infer TypeArgs>(...args: infer Args) => infer R
    ? (x: number, ...args: Args<...TypeArgs>) => R<...TypeArgs>
    : never;
// ...

// or maybe
type X1 = F extends <...infer TypeArgs>(...args: infer Args<...TypeArgs>) => infer R<...TypeArgs>
    ? (x: number, ...args: Args<...TypeArgs>) => R<...TypeArgs>
    : never;
// ...
  1. Alternative extension to existing syntax (in general, but here's one possibility):
type F = <T, R>(t: T) => R;

type X1 = <...Args>(x: number, ...args: Parameters<F<...Args>>) => ReturnType<F<...Args>>;

πŸ“ƒ Motivating Example

Consider some kind of API/middleware builder that looks like this when used:

builder
    .add('slow', { rate: 500 })
    .add('get', { query: ['abc'] as const }, ({ query: { abc } }) => {
        // ...
    };

It could be typed as something like this:

interface Builder {
    add(type: 'slow', options: { rate: number }): Builder;
    add<Q extends readonly string[]>(
        type: 'get',
        options: { query: Q },
        callback: (data: { query: { [K in Q[number]]: string } }) => void
    ): Builder;
    // ...
}

However, now we want third parties to be able to add their own middleware types to the builder. Making an API for adding them is fairly easy:

BuilderFactory.register('merge', (options, callback) => {
    // ...
    callback(...);
    // ...
} );

But typing it is much harder. Usually we would use module augmentation to allow third parties to type their middleware types:

interface Builder {
    add<T extends keyof BuilderMethods>(type: T, ...args: Parameters<BuilderMethods[T]>): ReturnType<BuilderMethods[T]>
}

// third party code
declare module '...' {
    interface BuilderMethods {
        merge: <Q extends readonly string[]>(
            options: { query: Q },
            callback: (data: { query: { [K in Q[number]]: string } }) => void
        ) => void;
    }
}

But if we do this, we lose all of the information encoded in the type parameters. This feature would allow the add method to retain the generic type information of the function type it's derived from.

πŸ’» Use Cases

My use case would be relatively similar to the motivating example above. Currently my workaround is to just only have a good typing experience for first-party methods, but that's not ideal.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature labels Aug 29, 2022
@ecyrbe
Copy link

ecyrbe commented Dec 28, 2023

At the moment many libraries (fp-ts, effect-ts) resolve around an implementation of typeclasses that are really ugly.
They need to preemptively add generic parameters to all functions to not loose generic type information when chaining calls.
It would be nice to have a way to manipulate generics functions aliases like we manipulate arrays.

Here an example of what one would need to do to implement a Functor that handles up to 4 parameters.
Don't mind too much about $, it's just an HKT with a lot of worarounds.
$<F,[A]> is an HKT, equivalent if calling a utility type F<A>.
For an array kind it does the following :

type Test<A> = $<Kind.Array, [A]>;
// same as
type Test<A> = A[]

So here what we have to do today to implement a Functor that fixes the first type, and let the others generic

export interface Functor<F extends Kind> {
  map: F extends Kind.unary
    ? <A, B>(f: (a: A) => B) => (fa: $<F, [A]>) => $<F, [B]>
    : F extends Kind.binary
      ? <A, B>(f: (a: A) => B) => <C>(fa: $<F, [A, C]>) => $<F, [B, C]>
      : F extends Kind.ternary
        ? <A, B>(
            f: (a: A) => B
          ) => <C, D>(fa: $<F, [A, C, D]>) => $<F, [B, C, D]>
        : F extends Kind.quaternary
          ? <A, B>(
              f: (a: A) => B
            ) => <C, D, E>(fa: $<F, [A, C, D, E]>) => $<F, [B, C, D, E]>
          : never;
}

The nightmare begins when you want to compose Functors with other Functors. Handling all uses cases become exponential.
So what it could look with some helpers :

export interface Functor<F extends Kind> {
  map:  <A, B>(f: (a: A) => B) => <...Arity<F>>(fa: $<F, [A, ...Arity<F>]>) => $<F, [B, ...Arity<F>]>

@denis-migdal
Copy link

denis-migdal commented Jan 25, 2024

I'd like to suggest 2 other solutions :

Solution 1:

Add a AsGeneric<> tool :

type X = <T,R>(e: T) => R;
type Y<T> = AsGeneric<X, T, string> // Y<T> = <T,string>(e: T) => string

Solution 2:

Allow to use satisfies with types :

class Base<T> {}
class Child<T> extends Base<T> {}

type X = Child<unknown>
type Y = X satisfies Base<string>; // Y = Child<string>

With that, we can then use some workarounds.

@denis-migdal

This comment was marked as outdated.

@denis-migdal
Copy link

denis-migdal commented Feb 25, 2024

Hi,

I found an improvement for the previous workaround that may be useful in some cases, by simulating a kind of map of types.

Like the previous workaround, the drawback is that we have to manually register the types/shapes we wish to manipulate.
For methods, maybe there is a way to do something more generic using some kind of recursion ?

{
	class ClassA<T> {
		fooA() {}
	}
	class ClassB<T> {
		fooB(){}
	}

	const SYMA = Symbol();
	const SYMB = Symbol();
	interface TypeMap<T> {
		[SYMA]: [ClassA<unknown>, ClassA<T>],
		[SYMB]: [ClassB<unknown>, ClassB<T>]
	}

	type Convert<T, U> = {
		[K in keyof TypeMap<U>]: TypeMap<U>[K][0] extends T ? TypeMap<U>[K][1] : never
	}[keyof TypeMap<U>];

	type TA = ClassA<unknown>;
	type TB = ClassB<unknown>;

    // we can convert without knowing the real type of TA/TB.
	type ResultA  = Convert<TA, boolean>; // ClassA<boolean>
	type ResultB  = Convert<TB, boolean>; // ClassB<boolean>
}

Another drawback is that we need to set all generic type.
But maybe there is a way to add some kind of inference ?

interface InferMap<T> {
		[SYMA]: [ClassA<any>, InferStuffA<T>],
		[SYMB]: [ClassB<any>, InferStuffB<T>]
}

We could then use it to get a kind of tuple of types we could then re-inject using Convert ?
Manipulating such tuple of type could also be possible with some kind of recursion ?

Append<T, U> = [T[0], T[1], U]; // but with recursions.

Therefore I think a more complete/generic workaround could be possible ?

EDIT: depending on the needs, for a stricter check :

	type Convert<T, U> = {
		[K in keyof TypeMap<U>]: TypeMap<U>[K][0] extends T
											? T extends TypeMap<U>[K][0] 
																? TypeMap<U>[K][1]
																: never
											: never
	}[keyof TypeMap<U>];

If needed, the resulting union can also be transformed into an intersection.

@denis-migdal
Copy link

In some other cases, maybe a workaround like this can also be used:

type P<T> = Parameters<F<T>>
type R<T> = ReturnType<F<T>>

type P2<T> = // some manipulations on P<T>

type F2<T> = <T>(...args: P2<T>) : R<T>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

4 participants