Mapped types of the form {[K in keyof T]: ...} where T is some type parameter are called homomorphic, as introduced in microsoft/TypeScript#12447 (which calls them isomorphic). In that pull request, it says
when a primitive type is substituted for T in an isomorphic mapped type, we simply produce that primitive type.
If a primitive type like string goes in, the same primitive type comes out:
type SomeHomomorphicMappedType<T> = { [K in keyof T]: Date };
type MappedString = SomeHomomorphicMappedType<string>; // string
Since ValidationSchema is a homomorphic recursive mapped type, when it recurses down into the name and age properties of Person, it gets applied as ValidationSchema<string | undefined> for name and ValidationSchema<number | undefined> for age. Primitive in, primitive out:
type VSP = ValidationSchema<Person>
/* type VSP = {
name?: string | ValidateFn | ValidateFn[] | undefined;
age?: number | ValidateFn | ValidateFn[] | undefined;
} */
So, that explains it.
If you want some other behavior you might want to use conditional types to do something different when T extends object vs when it doesn't. Maybe like this:
type ValidationSchemaMaybe<T> = {
[P in keyof T]: Array<ValidateFn> | ValidateFn | (
T[P] extends object ? ValidationSchemaMaybe<T[P]> : never
);
}
type VSPMaybe = ValidationSchemaMaybe<Person>
/* type VSPMaybe = {
name?: ValidateFn | ValidateFn[] | undefined;
age?: ValidateFn | ValidateFn[] | undefined;
} */
Okay, hope that helps; good luck!
Playground link to code