export type Parser = NumberParser | StringParser;
type NumberParser = (input: string) => number | DiplomacyError;
type StringParser = (input: string) => string | DiplomacyError;
export interface Schema {
[key: string]: Parser | Schema;
}
export type RawType<T extends Schema> = {
[Property in keyof T]: T[Property] extends Schema
? RawType<T[Property]>
: ReturnType<Exclude<T[Property], Schema>>;
};
// PersonSchema is compliant the Schema interface, as well as the address property
const PersonSchema = {
age: DT.Integer(DT.isNonNegative),
address: {
street: DT.String(),
},
};
type Person = DT.RawType<typeof PersonSchema>;
Sadly type Person
is inferred as:
type Person = {
age: number | DT.DiplomacyError;
address: DT.RawType<{
street: StringParser;
}>;
}
Instead I would have liked to get:
type Person = {
age: number | DT.DiplomacyError;
address: {
street: string | DT.DiplomacyError;
};
}
What am I missing?
The difference between the Person
displayed and the type you expected is pretty much just cosmetic. The compiler has a set of heuristic rules it follows when evaluating and displaying types. These rules have changed over time and are occasionally tweaked, such as the "smarter type alias preservation" support introduced in TypeScript 4.2.
One way to see that the types are more or less equivalent is to create both of them:
type Person = RawType<PersonSchema>;
/*type Person = {
age: number | DiplomacyError;
address: RawType<{
street: StringParser;
}>;
}*/
type DesiredPerson = {
age: number | DiplomacyError;
address: {
street: string | DiplomacyError;
};
}
And then see that the compiler considers them mutually assignable:
declare let p: Person;
let d: DesiredPerson = p; // okay
p = d; // okay
The fact that those lines did not result in a warning means that, according to the compiler, any value of type Person
is also a value of type DesiredPerson
, and vice versa.
So maybe that's enough for you.
If you really care about how the type is represented, you can use techniques such as described in this answer:
// expands object types recursively
type ExpandRecursively<T> = T extends object
? T extends infer O ? { [K in keyof O]: ExpandRecursively<O[K]> } : never
: T;
If I compute ExpandRecursively<Person>
, it walks down through Person
and explicitly writes out each of the properties. Assuming that DiplomacyError
is this (for want of a minimal reproducible example in the question):
interface DiplomacyError {
whatIsADiplomacyError: string;
}
Then ExpandRecurively<Person>
is:
type ExpandedPerson = ExpandRecursively<Person>;
/* type ExpandedPerson = {
age: number | {
whatIsADiplomacyError: string;
};
address: {
street: string | {
whatIsADiplomacyError: string;
};
};
} */
which is closer to what you want. In fact, you could rewrite RawType
to use this technique, like:
type ExpandedRawType<T extends Schema> = T extends infer O ? {
[K in keyof O]: O[K] extends Schema
? ExpandedRawType<O[K]>
: O[K] extends (...args: any) => infer R ? R : never;
} : never;
type Person = ExpandedRawType<PersonSchema>
/* type Person = {
age: number | DiplomacyError;
address: {
street: string | DiplomacyError;
};
} */
which is exactly the form you wanted.
(Side note: there is a naming convention for type parameters, as mentioned in this answer. Single capital letters are preferred over whole words, so as to distinguish them from specific types. Hence, I have replaced Property
in your examples with K
for "key". It might seem paradoxical, but because of this convention, K
is more likely to be immediately understood by TypeScript developers to be a generic property key than Property
is. You are, of course, free to continue using Property
or anything else you like; it's just a convention, after all, and not some sort of commandment. But I just wanted to point out that the convention exists.)