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?
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,
]);