typescript

Why does my inferred type fallback to a default case in a conditional type?


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.


Solution

  • 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:

    Playground

    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";
            };
        };
    }
    */