Assuming you have the --strictNullChecks compiler option enabled, then the type of name and age properties of IPerson are not string and number; rather, they are string | undefined and number | undefined. Optional properties automatically have the undefined type added to their domain (if you enable the --exactOptionalPropertyTypes compiler option this is a bit more complicated but it's still true that if you read an optional property it may include undefined). And string | undefined does not extend string, so everything falls through to unknown and you are sad.
Perhaps the most expedient way of dealing with this is then to just test extends string | undefined instead of extends string, since string extends string | undefined is true:
const test: { [K in keyof IPerson]: {
type: IPerson[K] extends string | undefined ? StringConstructor
: IPerson[K] extends number | undefined ? NumberConstructor
: unknown
} }
= {
name: { type: String },
age: { type: Number },
};
/* const test: {
name?: {
type: StringConstructor;
} | undefined;
age?: {
type: NumberConstructor;
} | undefined;
} */
Note that there are more general solutions which will take a property of type string | number and turn it to a value of type StringConstructor | NumberConstuctor; that is, they will distribute your type operation over union types; or solutions which will take a mapping type like [string, StringConstructor] | [number, NumberConstructor] | ... and use it to programmatically translate your types so that you can just add new elements like [boolean, BooleanConstructor] and it would work. But all of these are out of scope for the question as asked, so I won't go into them for this question.
Playground link to code