3

If I have an existing type definition of:

type Person = {
  name: string;
  age: number;
  sayHello: (greeting: string) => void;
}

is it possible to some how build a typed array/tuple of the keys AND types of this definition?

For instance if I would like to have a type like:

type PropAndTypes = ( 
  ['name',string] | 
  ['age',number]  | 
  ['sayHello', (arg: string) => void]
)[];

Can this somehow be generated from the existing type definition?

For less of an esoteric example, the specific use case would be to help create type safe State Declarations for a routing library (ui-router) given the expected props of the linked component.

An example state declaration has a component field, and the resolves that will be passed to the component.

const exampleStateDefinition = {
  name: 'example.state',
  url: '/:name/:age',
  component: ExampleComponent,
  resolve: [
    {
      token: 'age' as const,
      deps: [Transition],
      resolveFn: (trans: Transition) => trans.params<Person>().age,
    },
    {
      token: 'name' as const,
      deps: [Transition],
      resolveFn: (trans: Transition) => trans.params<Person>().name,
    },
    {
      token: 'sayHello' as const,
      deps: [Transition],
      resolveFn: () => (greeting: str) => console.log(greeting),
    },
  ],
};

I would like to generate a type definition like:

type stateDeclaration = {
    resolve: ResolveTypes[];
}

where ResolveTypes is

type ResolveTypes = { token: 'name', resolve: string } | { token: 'age', resolve: number }; 
  • 1
    I think that the first example resembles what `Object.entries` produces, in the case of which you can do it as outlined in [this question's](https://stackoverflow.com/q/60141960/996081) answers. – cbr Jan 14 '21 at 00:46
  • This is exactly what I was looking for, thank you very much. Do you happen to know of a way to take this new union type and create an array type requiring _each_ type is present? This seems like it would require a tuple, and converting from a union to a tuple is not recommended ([here](https://stackoverflow.com/a/55128956/5983820)) – John Lenehan Jan 14 '21 at 01:25
  • 1
    Well, I don't think you're looking for converting the tuple's values into an ordered array, but if I'm guessing correctly, trying to create an union type of the type of an object's property (resolve) which is an array, and specifically pick the possible `token` properties and their respective resolveFn's inferred return value's types into an union. I wonder if you can somehow manipulate `typeof exampleStateDefinition['resolve']` - not sure if it's already inferred to be an union type? If so, try picking `token` from that object and inferring the respective return type. – cbr Jan 14 '21 at 02:09
  • 1
    Ah, just saw your question about requiring each type in an union to be present in an array. Sorry, I don't know how to do that. Yeah, probably could construct a type which is an union of all possible permutations somehow, but...I don't know. – cbr Jan 14 '21 at 02:17

1 Answers1

1

So here's what I came up with for the "less of an esoteric example" (playground):

const ExampleComponent = (props: { hey: string }) => ({ hey: props.hey });
interface Person {
  age: number;
  name: string;
}
interface Transition {
  params: <T>() => T;
}
const transition = { params: () => ({ age: 1, name: "aaa" }) };

const exampleStateDefinition = {
  name: "example.state",
  url: "/:name/:age",
  component: ExampleComponent,
  resolve: [
    {
      token: "age" as const,
      deps: [transition],
      resolveFn: (trans: Transition) => trans.params<Person>().age,
    },
    {
      token: "name" as const,
      deps: [transition],
      resolveFn: (trans: Transition) => trans.params<Person>().name,
    },
    {
      token: "sayHello" as const,
      deps: [transition],
      resolveFn: () => (greeting: string) => console.log(greeting),
    },
  ],
};

type ResolverIn<T, R> = {
  token: T;
  resolveFn: R;
};
type ResolverOut<T, R> = {
  token: T;
  resolve: R;
};

type T1<T> = T extends Array<infer U>
  ? U extends ResolverIn<infer V, infer R>
    ? R extends (...args: any) => infer Ret
      ? ResolverOut<V, Ret>
      : never
    : never
  : never;

type ResolveTypes = T1<typeof exampleStateDefinition["resolve"]>;
// = ResolverOut<"age", number> | ResolverOut<"name", string> | ResolverOut<"sayHello", (greeting: string) => void>
// which is:
// { token: "age", resolve: number } | { token: "name", resolve: string } | { token: "sayHello", resolve: (greeting: string) => void }

type StateDeclaration = {
  resolve: ResolveTypes;
};

With the meat of the matter being

type T1<T> = T extends Array<infer U>
  ? U extends ResolverIn<infer V, infer R>
    ? R extends (...args: any) => infer Ret
      ? ResolverOut<V, Ret>
      : never
    : never
  : never;

Here I first use a conditional to ensure the type is an array and infer the type of the array elements, then infer the shape to be ResolverIn (sort of a Pick<>), then infer the return type of the resolveFn function (like ReturnType<T>, but we've just inferred the type so we need to infer again to further constrain the type to be a function) and finally produce the shape that we want which is ResolverOut<V, Ret>.

The type of ResolveTypes thus becomes:

ResolverOut<"age", number> |
ResolverOut<"name", string> |
ResolverOut<"sayHello", (greeting: string) => void>

whose shape is equivalent to:

{ token: "age"; resolve: number } |
{ token: "name"; resolve: string } |
{ token: "sayHello"; resolve: (greeting: string) => void }

Additionally, your example excludes the resolver types whose return value is a function, which can be filtered out with another conditional:

type T1<T> = T extends Array<infer U>
  ? U extends ResolverIn<infer V, infer R>
    ? R extends (...args: any) => infer Ret
      ? Ret extends (...args: any) => any
        ? never
        : ResolverOut<V, Ret>
      : never
    : never
  : never;

Edit: Now, I didn't get the chance to test this, but to produce StateDeclaration directly from typeof exampleStateDefinition, you can probably do something like this:

type T2<T> = T extends { resolve: infer U } ? { resolve: T1<U> } : never;
type StateDeclaration = T2<typeof exampleStateDefinition>;

Edit 2: I was able to get a bit closer to what you clarified in the comments with this answer which uses an utility function (which just returns the array passed to it as-is) to enforce that the array passed to it contains all elements from the union type. Playground.

interface Person {
  age: number;
  name: string;
}
interface Transition {
  params: <T>() => T;
}

type ResolveType<T> = {
  [K in keyof T]: { token: K; resolveFn: (...args: any[]) => T[K] };
}[keyof T];

type ResolveTypes<T> = ResolveType<T>[]


function arrayOfAll<T>() {
  return function <U extends T[]>(array: U & ([T] extends [U[number]] ? unknown : 'Invalid')) {
    return array;
  };
}

interface CustomStateDeclaration<T> {
  name: string;
  url: string;
  component: any;
  resolve: ResolveTypes<T>;
}

type ExampleComponentProps = {
  age: number;
  name: string;
  sayHello: (greeting: string) => string;
};

const arrayOfAllPersonResolveTypes = arrayOfAll<ResolveType<ExampleComponentProps>>()
// passes
const valid = arrayOfAllPersonResolveTypes([
  {
    token: "age" as const,
    resolveFn: (trans: Transition) => trans.params<Person>().age,
  },
  {
    token: "name" as const,
    resolveFn: (trans: Transition) => trans.params<Person>().name,
  },
  {
    token: "sayHello" as const,
    resolveFn: () => (greeting: string) => `Hello, ${greeting}`,
  },
])

// error; missing the "sayHello" token
const missing1 = arrayOfAllPersonResolveTypes([
  {
    token: "age" as const,
    resolveFn: (trans: Transition) => trans.params<Person>().age,
  },
  {
    token: "name" as const,
    resolveFn: (trans: Transition) => trans.params<Person>().name,
  }
])

// error; "name" token's resolveFn returns a number instead of a string
const wrongType = arrayOfAllPersonResolveTypes([
  {
    token: "age" as const,
    resolveFn: (trans: Transition) => trans.params<Person>().age,
  },
  {
    token: "name" as const,
    resolveFn: (trans: Transition) => 123,
  },
  {
    token: "sayHello" as const,
    resolveFn: () => (greeting: string) => `Hello, ${greeting}`,
  },
])

Could try making a type that does what the utility function does, or create a state definition factory/constructor that all state declarations need to be created with (enforced by a symbol, perhaps), which uses that utility function.

cbr
  • 12,563
  • 3
  • 38
  • 63
  • Thank you for the detailed response. It looks like the `ResolveTypes` is still a union type, so it's not possible to ensure all values are present in `resolve: ResovleTypes[]` correct? Also, apologies as I realize this wasn't clear, but my main goal here is to create a generic type that the _original state declaration_ can use (`const exampleStateDefinition: CustStateDec`) so that the resolves are validated, rather than derive the type from the state declaration. Using the examples in your original comments, I've created the following playground that gets me partially there. – John Lenehan Jan 14 '21 at 15:14
  • Playground link was too long, here it is on [codesandbox](https://codesandbox.io/s/y0mc3) – John Lenehan Jan 14 '21 at 15:17
  • @JohnLenehan Thanks for the feedback. I edited my answer - I think we're close! – cbr Jan 14 '21 at 15:50
  • 1
    This looks great! Using a helper function to ensure type safety seems like the cleanest approach without too much of a heavy lift on the developer. Creating a type that mimics the behavior of the utility function would be the ideal scenario but this does everything that I'm looking for. I'll dig a bit more into converting this into a type (after reading up more on the linked comments) and update if I can come up with something. – John Lenehan Jan 14 '21 at 16:19
  • One issue I've run into with this helper method is when expanding the type definition of `ResolveType`. The only parts that I need to be type-checked are `token` and the `resolveFn`, but there are a few optional fields as well. For instance: `{ token: "age" as const, deps: [Transition], data: {arbitrary: 'data'}, resolveFn: (trans: Transition) => trans.params().age,}` I had assumed I could simply update `ResolveType` to include: `{ ... deps?: any[], data?: Record }` But this breaks the utility function. – John Lenehan Jan 14 '21 at 19:55
  • @JohnLenehan Damn! It looks like it's the optionality that breaks it - if you change `deps?` and `data?` to `deps` and `data`, it works :P – cbr Jan 14 '21 at 23:16