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

[data] Enable manually specifying curried selector signatures #41578

Conversation

adamziel
Copy link
Contributor

@adamziel adamziel commented Jun 7, 2022

What problem is this PR solving?

The CurriedState provided in #39081 does not correctly curry complex type signatures.

What's CurriedState?

The CurriedState type is provided to remove the state argument from selectors function signatures:

type SimpleSelector = (state: {}, myNumberArg: number) => string;
const selector = {} as SimpleSelector;
selector({}, 15);

const curried = {} as CurriedState<SimpleSelector>;
curried(15);

It's needed to transform the definitions in selectors.ts that accept the state argument, to signatures exposed by useSelect(select => select( store )./*...*/) that do not accept the state argument`

Where does it fall short?

Here's a minimalistic example:

type BadlyInferredSignature = CurriedState<
    <K extends string | number>(
        state: any,
        kind: K,
        key: K extends string ? 'one value' : false
    ) => K
>
// BadlyInferredSignature evaluates to:
// (kind: string number, key: false | "one value") => string number

What solution does this PR propose?

I don't believe TypeScript supports the kind of automatic transformation the CurriedState wants to do.

This PR provides an escape hatch to manually specify the curried signatures when TypeScript falls short:

interface MySelectorSignature extends SelectorWithCustomCurrySignature {
    <K extends string | number>(
        state: any,
        kind: K,
        key: K extends string ? 'one value' : false
    ): K;

    CurriedSignature: <K extends string | number>(
        kind: K,
        key: K extends string ? 'one value' : false
    ): K;
}
type CorrectlyInferredSignature = CurriedState<MySelectorSignature>
// <K extends string | number>(kind: K, key: K extends string ? 'one value' : false): K;

What else has been tried?

I've tried a ton of different ways to infer things automatically.

Here's the basic setup used in all the attempts:

interface CommentEntityRecord { id: number; content: string; }
interface PostEntityRecord { id: string; title: string; }

type EntityConfig =
    | { kind: 'root', record: CommentEntityRecord, keyType: number }
    | { kind: 'postType', record: PostEntityRecord, keyType: string }

const getEntityRecords = <K extends EntityConfig['kind']>(
    kind: K,
    key: Extract<EntityConfig, { kind: K }>['keyType']
): Extract<EntityConfig, { kind: K }>['record'] => { return {} as any; };

const getEntityRecordsWithState = <K extends EntityConfig['kind']>(
    state: any,
    kind: K,
    key: Extract<EntityConfig, { kind: K }>['keyType']
): Extract<EntityConfig, { kind: K }>['record'] => { return {} as any; };

Instead of trying to do the Curry, which clones the signature with one argument less, I tried to just clone the signature which seems easier and requires less complexity.

My ideal Clone type helper would do the following:

type ClonedF = Clone<F>;
// ClonedF is the same as F.

The only constraint is I am not allowed to simply do this:

type Clone<F> = F;

After many hours, I don't think such type can be created in a way that works with generic functions.

Universal clone like CloneAnyFunction<F>

In this scenario, we need to derive F somehow

One way to infer the function type is to use the infer TypeScript feature:

type InferredSignature = typeof getEntityRecords extends (...args:infer A) => infer R ? (...args:A) => R : never;
//   ^? (kind: "root" | "postType", key: string | number) => CommentEntityRecord | PostEntityRecord

InferredSignature type is not constrained in the same way as the actual arguments are, so this is dead end.

Another way would be to use the typeof operator:

type TypeOfSignature = typeof getEntityRecords;
//   ^? <K extends "root" | "postType">(kind: K, key: (Extract<{kind:  //...

It seems like a step in the right direction – the TypeOfSignature type reflects all the constraints we care about.

Unfortunately, there isn't much we can do with them. For example, we can't extract them with the Parameters<> helper:

type SelectorArguments = Parameters<TypeOfSignature>
//   ^? [kind: "root" | "postType", key: string | number]

SelectorArguments is the same as args of the InferredSignature – which tells me that raw typeof is a dead end.

There's a twist on typeof called "instantiation expressions":

type InstantiatedTypeOfSignature = typeof getEntityRecords<'root'>;
//   ^? (kind: "root", key: number) => CommentEntityRecord

It is better in that the signature reflects the actual constraints imposed on the getEntityRecords definition. The problem now is that it's only for the specific kind value of 'root'. We want to make it work for any possible kind value.

Let's try passing a type union:

type InstantiatedTypeOfSignatureUnion = typeof getEntityRecords<'root' | 'postType'>;
//   ^? (kind: "root" | "postType", key: string | number) => CommentEntityRecord | PostEntityRecord

Nope, this signature lacks the constraints as well.

Finally, we could try parametrizing the signature type as follows:

type ParametrizedSignature<Param0> = typeof getEntityRecords<Param0>;

The problem here is that we'd need to specify the exact same constraints as getEntityRecords, otherwise we get this error:

Type 'Generic1' does not satisfy the constraint '"root" | "postType"'

The only solution seems to be really:

type SpecializedSignature<Param0 extends EntityConfig['kind']> = typeof getEntityRecords<Param0>;
//   ^? <Param0 extends "root" | "postType">(kind: Param0, key: (Extract<{kind:  //...

The only thing we did here, though, is we moved the Kind from a function to a proxy type. We still have all the same problems as before, e.g.:

type SadlyUnspecializedArguments = Parameters<SpecializedSignature<EntityConfig['kind']>>
//   ^? [kind: "root" | "postType", key: string I number]
type SadlyUnspecializedClone = SpecializedSignature<EntityConfig['kind']> extends (...args:infer A) => infer R ? (...args:A) => R : never;
//   ^? (kind: "root" | "postType", key: string | number) => CommentEntityRecord | PostEntityRecord

This tells me that TypeScript doesn't support a universal Clone type such as the one below:

type CloneAnyFunction<F> = /* ... */;

F must be either instantiated type or parametrized with generics, but there aren't any TS features that enable extracting the nuanced, constrained argument types from it.

Parametrized clone like CloneAnyFunction<Arg0, Arg1, Return>

I've also tried my luck with Parametrization

There aren't many ways of formulating such a parametrized clone type I can think of. I only found the following Curry implementation on StackOverflow:

type FN1<A, R>             = (a: A) => R
type FN2<A, B, R>          = ((a: A, b: B) => R)             | ((a: A) => FN1<B, R>)
type FN3<A, B, C, R>       = ((a: A, b: B, c: C) => R)       | ((a: A, b: B) => FN1<C, R>)       | ((a: A) => FN2<B, C, R>)

interface Curry {
    <A, R>(fn: (arg0:A) => R): FN1<A, R>
    <A, B, R>(fn: (arg0:A, arg1:B) => R): FN2<A, B, R>
    <A, B, C, R>(fn: (arg0:A, arg1:B, arg2:C) => R): FN3<A, B, C, R>
}

const curry = {} as Curry;
curry(getEntityRecords);

Unfortunately, we're running into the same kind of "insufficient constraints" errors as before:

 Types of parameters 'kind' and 'arg0' are incompatible.
  Type 'unknown' is not assignable to type '"root" | "postType"'.(2769)

We could, of course, specify the constraints explicitly:

type FN1Explicit<A, R>             = (a: A) => R
type FN2Explicit<A extends EntityConfig['kind'], B, R>          = ((a: A, b: B) => R)             | ((a: A) => FN1Explicit<B, R>)
type FN3Explicit<A extends any, B extends EntityConfig['kind'], C, R>       = ((a: A, b: B, c: C) => R)       | ((a: A, b: B) => FN1Explicit<C, R>)       | ((a: A) => FN2Explicit<B, C, R>)

interface CurryExplicit {
    <A, R>(fn: (arg0:A) => R): FN1Explicit<A, R>
    <A extends EntityConfig['kind'], B extends Extract<EntityConfig, { kind: A }>['keyType'], R>(fn: (arg0:A, arg1:B) => R): FN2Explicit<A, B, R>
    <A, B extends EntityConfig['kind'], C, R>(fn: (arg0:A, arg1:B, arg2:C) => R): FN3Explicit<A, B, C, R>
}

const curryExplicit = {} as CurryExplicit;
const curriedWithExplicitConstraints = curryExplicit(getEntityRecords);
type CurriedWithExplicitConstraints = typeof curriedWithExplicitConstraints;
//   ^?          ^? (kind: "root" | "postType", key: string | number) => CommentEntityRecord | PostEntityRecord

But even after all this effort, we're back to the non-specific type signature.

Parametrized arguments tuple

Once the type constraints are baked into a function type signature, they are stuck there. There seems to be no TypeScript feature that can split the function apart into a constrained arguments type and a constrained return type.

But what if we define the arguments tuple manually?

type Kind = EntityConfig['kind'];

type SelectorArgs<K extends Kind> = [
    state: any,
    kind: K,
    key: Extract<EntityConfig, { kind: K }>['keyType']
]

Oki, now let's slice it:

type SlicedArgs<K extends Kind> = SelectorArgs<K> extends [any, infer A1, infer A2] ? [A1, A2] : never;
const selectorWithSlicedArgs = <K extends Kind>(...args:SlicedArgs<K>) => null;

Uh-oh! selectorWithSlicedArgs accepts the unconstrained arguments. We can't even slice the parametrized tuple in a way that preserves the constraints! This leaves us with only one option: provide the curried and uncurried type signatures manually :(

And it's exactly what this PR proposes.

Manually specifying both signatures

We want to do two things with selectors like getEntityRecords:

  • Call them
  • Transform their signature into a curried form

Let's define a type that is both callable and also contains information about the manually-defined curried signature:

interface SelectorWithCustomCurrySignature {
    __isCurryContainer: true;
    CurriedSignature: Function;
}

interface GetEntityRecords_AsContainer extends SelectorWithCustomCurrySignature {
    <K extends EntityConfig['kind']>(
        state: any,
        kind: K,
        key: Extract<EntityConfig, { kind: K }>['keyType']
    ): Extract<EntityConfig, { kind: K }>['record'];

    CurriedSignature: <K extends EntityConfig['kind']>(
        kind: K,
        key: Extract<EntityConfig, { kind: K }>['keyType']
    ) => Extract<EntityConfig, { kind: K }>['record'];
}

Here's a proof it can be called just like a regular function:

const getEntityRecords_container = {} as GetEntityRecords_AsContainer;
const result1 = getEntityRecords_container({}, 'root', '15'); // Type error on the third argument as expected
const result2 = getEntityRecords_container({}, 'root', 15);   // result2 is CommentEntityRecord as expected
const result3 = getEntityRecords_container({}, 'postType', '15'); // result2 is PostEntityRecord as expected
const result4 = getEntityRecords_container({}, 'postType', 15);   // Type error on the third argument as expected

It even respects our type constraints, great!

Now, let's try currying:

// Here's the CurriedState implementation provided by
// [data] Fill out type definition for data registry (https://github.com/WordPress/gutenberg/pull/39081)
// type CurriedState< F > = F extends ( state: any, ...args: infer P ) => infer R
// 	? ( ...args: P ) => R
// 	: F;

// And here's a slightly augmented one that recognizes "curry containers":
type CurriedState< F > =
    F extends SelectorWithCustomCurrySignature
        ? F['CurriedSignature']
        :
    F extends ( state: any, ...args: infer P ) => infer R
	    ? ( ...args: P ) => R
	    : F;

type CurriedGetEntityRecords = CurriedState<GetEntityRecords_AsContainer>;
//   ^?
// Yes! Finally success

const getEntityRecords_curried = {} as CurriedGetEntityRecords;
const result1_curried = getEntityRecords_curried('root', '15'); // Type error on the third argument as ezpected
const result2_curried = getEntityRecords_curried('root', 15);   // result2 is CommentEntityRecord as expected
const result3_curried = getEntityRecords_curried('postType', '15'); // result2 is PostEntityRecord as expected
const result4_curried = getEntityRecords_curried('postType', 15);   // Type error on the third argument as ezpected

Brilliant! And, in case you wondered, CurriedState can still automatically curry any other function:

type SimpleSelector = (state: {}, myNumberArg: number) => string;
const simpleSelector = {} as SimpleSelector;
const result1_simple = simpleSelector({}, '15'); // Type error on the second argument as expected
const result2_simple = simpleSelector({}, 15);   // result2 is of type string as expected 

const simpleSelector_curried = {} as CurriedState<SimpleSelector>;
const result1_simple_curried = simpleSelector_curried('15'); // Type error on the only argument as expected
const result2_simple_curried = simpleSelector_curried(15);   // result2 is of type string as expected 

Testing Instructions

The checks are all red, that's fine – it's a PR to another branch based on a pretty old trunk

  1. Confirm the idea makes sense
  2. Confirm the implementation is sound
  3. Confirm it actually works as described above

cc @dmsnell @sarayourfriend @sirreal @jsnajdr

…tom curry signature for selectors when TypeScript inference falls short.
@adamziel adamziel added the [Package] Data /packages/data label Jun 7, 2022
@adamziel adamziel requested a review from nerrad as a code owner June 7, 2022 15:21
@adamziel adamziel self-assigned this Jun 7, 2022
@adamziel adamziel added the Developer Experience Ideas about improving block and theme developer experience label Jun 7, 2022
Copy link
Member

@dmsnell dmsnell left a comment

Choose a reason for hiding this comment

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

As discussed I'm growing more and more opposed to the work we've put in here in @wordpress/data typings, even the work I've personally done. This adds more onto the complexity of those types in a way that I'm nervous about maintaining, but given this mostly touches some existing cruft go ahead.

packages/data/src/types.ts Show resolved Hide resolved
@sarayourfriend
Copy link
Contributor

interface GetEntityRecords_AsContainer extends SelectorWithCustomCurrySignature {
    <K extends EntityConfig['kind']>(
        state: any,
        kind: K,
        key: Extract<EntityConfig, { kind: K }>['keyType']
    ): Extract<EntityConfig, { kind: K }>['record'];

    CurriedSignature: <K extends EntityConfig['kind']>(
        kind: K,
        key: Extract<EntityConfig, { kind: K }>['keyType']
    ) => Extract<EntityConfig, { kind: K }>['record'];
}

The repetition here seems perfect for a type constructor that would just thake the CurriedSignature bit and then know how to prepend state: any to the arguments list. Is that possible, I wonder?

@adamziel
Copy link
Contributor Author

adamziel commented Jul 7, 2022

@sarayourfriend I couldn't get that to work, unfortunately :-( The moment we separate the parameters from the signature, we're toast: TS Playground

packages/data/src/types.ts Outdated Show resolved Hide resolved
@adamziel
Copy link
Contributor Author

adamziel commented Jul 7, 2022

As discussed I'm growing more and more opposed to the work we've put in here in @wordpress/data typings, even the work I've personally done. This adds more onto the complexity of those types in a way that I'm nervous about maintaining, but given this mostly touches some existing cruft go ahead.

Agreed, I am also growing more and more skeptical of the approach we initially took.

This PR is for your branch, not for trunk so I'll go ahead and merge it.

That being said, I'd rather regroup and start shipping incremental improvements such as support for useSelect and useDispatch, or making TypeScript aware of the cross-package types inside of the repository.

@adamziel adamziel merged commit 5d40e69 into types/expand-data-registry Jul 7, 2022
@adamziel adamziel deleted the types/expand-data-registry-curry-containers branch July 7, 2022 12:01
adamziel added a commit that referenced this pull request Sep 26, 2022
…hrough signature currying in `@wordpress/data`.

Declare GetEntityRecord as a *callable interface* that is callable
as usually, but also ships another signature without the state argument.
This works around a TypeScript limitation that doesn't allow
currying generic functions:

```ts
type CurriedState = F extends ( state: any, ...args: infer P ) => infer R
    ? ( ...args: P ) => R
    : F;

type Selector = <K extends string | number>(
    state: any,
    kind: K,
    key: K extends string ? 'string value' : false
) => K;

type BadlyInferredSignature = CurriedState< Selector >
// BadlyInferredSignature evaluates to:
// (kind: string number, key: false | "string value") => string number
```

The signature without the state parameter shipped as CurriedSignature
is used in the return value of `select( coreStore )`.

See #41578 for more details.

This commit includes a docgen update to add support for typecasting
selectors
adamziel added a commit that referenced this pull request Sep 27, 2022
…tEntityRecords through currying (#44453)

Declare GetEntityRecord as a *callable interface* that is callable
as usually, but also ships another signature without the state argument.
This works around a TypeScript limitation that doesn't allow
currying generic functions:

```ts
type CurriedState = F extends ( state: any, ...args: infer P ) => infer R
    ? ( ...args: P ) => R
    : F;

type Selector = <K extends string | number>(
    state: any,
    kind: K,
    key: K extends string ? 'string value' : false
) => K;

type BadlyInferredSignature = CurriedState< Selector >
// BadlyInferredSignature evaluates to:
// (kind: string number, key: false | "string value") => string number
```

The signature without the state parameter shipped as CurriedSignature
is used in the return value of `select( coreStore )`.

See #41578 for more details.

This commit includes a docgen update to add support for typecasting
selectors
michalczaplinski pushed a commit that referenced this pull request Oct 3, 2022
…tEntityRecords through currying (#44453)

Declare GetEntityRecord as a *callable interface* that is callable
as usually, but also ships another signature without the state argument.
This works around a TypeScript limitation that doesn't allow
currying generic functions:

```ts
type CurriedState = F extends ( state: any, ...args: infer P ) => infer R
    ? ( ...args: P ) => R
    : F;

type Selector = <K extends string | number>(
    state: any,
    kind: K,
    key: K extends string ? 'string value' : false
) => K;

type BadlyInferredSignature = CurriedState< Selector >
// BadlyInferredSignature evaluates to:
// (kind: string number, key: false | "string value") => string number
```

The signature without the state parameter shipped as CurriedSignature
is used in the return value of `select( coreStore )`.

See #41578 for more details.

This commit includes a docgen update to add support for typecasting
selectors
ockham pushed a commit that referenced this pull request Oct 4, 2022
…tEntityRecords through currying (#44453)

Declare GetEntityRecord as a *callable interface* that is callable
as usually, but also ships another signature without the state argument.
This works around a TypeScript limitation that doesn't allow
currying generic functions:

```ts
type CurriedState = F extends ( state: any, ...args: infer P ) => infer R
    ? ( ...args: P ) => R
    : F;

type Selector = <K extends string | number>(
    state: any,
    kind: K,
    key: K extends string ? 'string value' : false
) => K;

type BadlyInferredSignature = CurriedState< Selector >
// BadlyInferredSignature evaluates to:
// (kind: string number, key: false | "string value") => string number
```

The signature without the state parameter shipped as CurriedSignature
is used in the return value of `select( coreStore )`.

See #41578 for more details.

This commit includes a docgen update to add support for typecasting
selectors
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Developer Experience Ideas about improving block and theme developer experience [Package] Data /packages/data
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants