0

I have a structure that describes a specific configuration value:

import * as z from 'zod';

export type ConfigurationProperty<V> = {
    type: string;
    // ...
    schema: z.Schema<V>; // Zod Schema
};

and it is relatively easy to produce a struct from a bunch of these properties:

export const DailyStandupTime = {
    type: "dailyStandupTime" as const,
    schema: z.string(),
}

export const DailyStandupTimeZone = {
    type: "dailyStandupTimeZone" as const,
    schema: z.string(),
};

export const DailyConfig = z.object({
    [DailyStandupTime.type]: DailyStandupTime.schema,
    [DailyStandupTimeZone.type]: DailyStandupTimeZone.schema,
});

export type DailyConfig = z.infer<typeof DailyConfig>;

here the DailyConfig type will correctly resolve to:

type DailyConfig = {
    dailyStandupTime: string;
    dailyStandupTimeZone: string;
}

but I'd like to write a general-purpose function that accepts a tuple of ConfigurationProperty objects like this:

const validate = <T>(data: Record<string, unknown>, props: ConfigurationProperty<any>[]): T => {
    throw new Error("Not implemented");
}


validate({a: 1, b: 2}, [DailyStandupTime, DailyStandupTimeZone]);

is it possible to somehow infer the type T from the tuple of ConfigurationProperty objects?

Adam Arold
  • 29,285
  • 22
  • 112
  • 207
  • 2
    Could you either tag this with `zod` or [edit] the code to be a [mre] that doesn't involve third-party libraries? Ideally we could copy and paste your code into our own IDEs and get to work on the issue without needing to try to handle types or values that are private or from third parties. Right now if I do that [I get lots of errors](https://tsplay.dev/WP8kkW). Could you fix those and put the fixed code as plain text in your question? That would help us get started. – jcalz Jun 06 '23 at 14:03
  • Sure, I removed all the fluff, now it will compile with zod. – Adam Arold Jun 06 '23 at 14:23
  • Does [this approach](https://tsplay.dev/w8YzMN) work for you? If yes, I'll write an answer explaining; If not, what am I missing? – wonderflame Jun 06 '23 at 14:38
  • That's not a solution, unfortunately. Instead of `string | number` it should be `type DailyConfig = { dailyStandupTime: string; dailyStandupTimeZone: string; }` – Adam Arold Jun 06 '23 at 14:49
  • Check [this one](https://tsplay.dev/Nl8nRN) please – wonderflame Jun 06 '23 at 15:02
  • Wow, what sort of trickery is this? – Adam Arold Jun 06 '23 at 18:40
  • So there is one small problem: what I need is not the schema itself, but the type it resolves to – Adam Arold Jun 06 '23 at 18:47
  • @AdamArold I can suggest something like [this](https://tsplay.dev/m3vk2N) would that work? – wonderflame Jun 06 '23 at 19:40
  • Or [this](https://tsplay.dev/wXr5Dm) – wonderflame Jun 06 '23 at 19:43
  • Yes, those work, would you put that in an answer? Also, can you explain the trickery that's happening there? My brain hurts just by looking at it – Adam Arold Jun 06 '23 at 21:46
  • Sure will do in a bit – wonderflame Jun 06 '23 at 21:47

1 Answers1

1

First, we will need to add a generic parameter for props and for the sake of testing let's make the return type T[number] which will result in the union of `props''s elements type:

const validate = <T extends ConfigurationProperty<any>[]>(
  data: Record<string, unknown>,
  props: T,
): T[number] => {
   return {} as any
};

Example:

// {
//     type: "dailyStandupTime";
//     schema: z.ZodString;
// } | {
//     type: "dailyStandupTimeZone";
//     schema: z.ZodNumber;
// }
const result = validate({ a: 1, b: 2 }, [
  DailyStandupTime,
  DailyStandupTimeZone,
]);

Now, using the mapped types we are going to convert {type: 'value', schema: SomeType} to {value: SomeType}. This type will accept a generic parameter constrained by ConfigurationProperty<unknown> that is the type of the props's elements. For this example, we will add a utility type Prettify that remaps the given type, which makes the type shown in the IDE readable:

type Prettify<T> = T extends infer R
  ? {
      [K in keyof R]: R[K];
    }
  : never;


type MapConfigurationProperty<T extends { type: string; schema: unknown }> = Prettify<{
  [K in T['type']]: T['schema'];
}

Let's update the validate as follows:

const validate = <T extends ConfigurationProperty<any>[]>(
  data: Record<string, unknown>,
  props: T,
): MapConfigurationProperty<T[number]> => {
  return {} as any;
};

Example:

// const result: {
//     dailyStandupTimeZone: z.ZodNumber | z.ZodString;
//     dailyStandupTime: z.ZodNumber | z.ZodString;
// }
const result = validate({ a: 1, b: 2 }, [
  DailyStandupTime,
  DailyStandupTimeZone,
]);

We can see that values in the result have the union of all possible types, which can be fixed by distributing types:

type MapConfigurationProperty<T extends ConfigurationProperty<unknown>> =
  Prettify<
    T extends T
      ? {
          [K in T['type']]: T['schema'];
        }
      : never
  >;

The same example will give us:

const result: {
    dailyStandupTime: z.ZodString;
} | {
    dailyStandupTimeZone: z.ZodNumber;
}

This is pretty close but we need to have these properties in a single object, not a union of one key and one value. To achieve this we are going to use UnionToIntersection described by @jcalz in this answer:

type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I,
) => void
  ? I
  : never;

type MapConfigurationProperty<T extends ConfigurationProperty<unknown>> =
  Prettify<
    UnionToIntersection<
      T extends T
        ? {
            [K in T['type']]: T['schema'];
          }
        : never
    >
  >;

Now the result will be:

const result: {
    dailyStandupTime: z.ZodString;
    dailyStandupTimeZone: z.ZodNumber;
}

Since you expect to have string and number instead of z.ZodString and z.ZodNumber respectively, we will infer the desired type from the latter ones. If we look in the zod's source code in here, we see that zodNumber extends ZodType<number,...>, thus we can infer the type from there by checking whether schema extends some ZodType:

type MapConfigurationProperty<T extends ConfigurationProperty<unknown>> =
  Prettify<
    UnionToIntersection<
      T extends T
        ? {
            [K in T['type']]: T['schema'];
          }
        : never
    > extends infer R
      ? {
          [K in keyof R]: R[K] extends z.ZodType<infer Extracted>
            ? [Extracted] extends [never]
              ? R[K]
              : Extracted
            : R[K];
        }
      : never
  >;

The inferred type R at that moment for the given example will be the same as the result in the last code snippet, where the values are zod types, thus we can check whether R[K], where K is the key of R extends some ZodType using the infer keyword, and the inferred type will be declared as Extracted. For type safety we will check whether Extracted is never and this is done as @kaya3 explain in this answer. If Extracted is never we keep the original Zod type, otherwise, we use Extracted itself.

Final testing and we get what we need:

// const result: {
//     dailyStandupTime: string;
//     dailyStandupTimeZone: number;
// }
const result = validate({ a: 1, b: 2 }, [
  DailyStandupTime,
  DailyStandupTimeZone,
]);

playground

wonderflame
  • 6,759
  • 1
  • 1
  • 17
  • 1
    Wow, thanks for the huge amount of work that went into this. I learned a lot about conditional and mapped types today! – Adam Arold Jun 07 '23 at 13:42