I'm writing a TypeScript package to infer the type of an express-validator
schema, but I'm having trouble with it.
When all the fields are of the same type—isString
, isInt
, isIn
—the type inference works. However, when one field is of a different type, it always falls into the "else" case of the InferPrimitive
type. In this case, it evaluates to 'ITS FAILING HERE'
. I know it's not an issue with the UnionToIntersection
type, because even when I remove it, the type is still inferred incorrectly.
Here’s the code for context:
import { Schema, ParamSchema } from 'express-validator';
type InferOptional<T extends ParamSchema> = 'optional' extends keyof T
? T['optional'] extends { options: { nullable: true } }
? InferPrimitive<T> | null | undefined
: InferPrimitive<T> | undefined
: InferPrimitive<T>;
export type InferIsIn<T extends ParamSchema> = T extends {
isIn: { options: [infer U extends readonly any[]] };
}
? U[number]
: never;
export type InferParam<T extends ParamSchema> = 'isIn' extends keyof T
? InferIsIn<T>
: InferOptional<T>;
export type InferPrimitive<T extends ParamSchema> = 'isString' extends keyof T
? string
: 'isEmail' extends keyof T
? string
: 'isAlpha' extends keyof T
? string
: 'isAlphanumeric' extends keyof T
? string
: 'isBoolean' extends keyof T
? boolean
: 'isInt' extends keyof T
? number
: 'isObject' extends keyof T
? {}
: 'ITS FAILING HERE';
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer I
) => void
? I
: never;
type InferSchema<
TSchema extends Schema,
TKey extends keyof TSchema = keyof TSchema,
TCurrentKey extends keyof TSchema = TKey
> = TCurrentKey extends `${infer K}.${infer R}`
? { [key in K]: InferSchema<TSchema, TKey, R> }
: { [key in TCurrentKey]: InferParam<TSchema[TKey]> };
type Infer<T extends Schema> = UnionToIntersection<InferSchema<T>>;
export const schema = {
name: {
isString: true,
optional: { options: { nullable: true } },
},
age: {
isInt: true,
},
address: {
isObject: true,
},
'address.address1': {
isString: true,
},
'address.address2': {
isString: true,
},
'address.number': {
isInt: true,
},
'address.city.name': {
isIn: { options: [['New York', 'Los Angeles'] as const] },
},
} satisfies Schema;
const inferred = {} as Infer<typeof schema>;
inferred
/*
The type being inferred is:
{
name: "ITS FAILING HERE";
} & {
age: "ITS FAILING HERE";
} & {
address: "ITS FAILING HERE";
} & {
address: {
address1: "ITS FAILING HERE";
};
} & {
address: {
address2: "ITS FAILING HERE";
};
} & {
...;
} & {
...;
}
*/```
I've already tried asking ChatGPT, removing the UnionToIntersection
type, setting the fallback to never
, and a few other things I can't quite remember right now.
First you could use a map type to avoid a lot of conditional types. Second, intersect only objects, since intersection of primitives would give just never
:
export type PrimitiveMap = {
isString: string
isEmail: string
isAlpha: string
isAlphanumeric: string
isBoolean: boolean
isInt: number
isObject: {}
}
type InferPrimitive<T extends ParamSchema> = {
[K in keyof T]: K extends keyof PrimitiveMap ? PrimitiveMap[K]: never
}[keyof T]
type SchemaMember<TSchema extends Schema, K extends keyof TSchema> =
K extends `${string}.${infer B}` ? InferSchema<{[key in B]: TSchema[K]}> : InferParam<TSchema[K]>
type InferSchema<TSchema extends Schema> = {
[K in keyof TSchema as K extends `${infer A}.${string}` ? A : K]: SchemaMember<TSchema, K> extends object ? UnionToIntersection<SchemaMember<TSchema, K>> : SchemaMember<TSchema, K>
}
/*
const inferred: {
name: string | null | undefined;
age: number;
address: {
address1: string;
address2: string;
number: number;
city: {
name: "New York" | "Los Angeles";
};
};
}
*/