I need help!
I'm trying to create a Schema
definition Type
based on a Type
given by user. The problem here is when I try to create a Type
that must be one of the value of an Array, the Schema
definition type is not working as expected.
For example, I have a user object and I need the value of the status
is an Enum
, which either verified
or created
. When defining the schema, I also need to make sure that the type
property is enum
and I have to define the enums
property which have all of the possible status (['verified', 'created']
).
My Schema
definition Type
is:
type Enum<T> = T;
// To convert typescript's type into string.
type TypeOf<T> = T extends object
? 'object'
: T extends Enum<unknown>
? 'enum'
: 'string';
// Schema definition is dynamic, based on the given T.
type Schema<T> = T extends object
? ObjectSchema<T>
: T extends Enum<unknown>
? EnumSchema<T>
: BaseSchema<T>;
type BaseSchema<T> = {
type: TypeOf<T>; // Make sure the type value is a correct type.
default?: T;
}
// When the given T is an Object.
type ObjectSchema<T> = BaseSchema<T> & {
fields: { [K in keyof T]?: Schema<T[K]> };
}
// If the given T is an Enum.
type EnumSchema<T> = BaseSchema<T> & {
enums: T[];
}
The usage sample:
// Create a User type.
type User = {
name: string;
status: Enum<'verified' | 'created'>;
}
// Create a User schema definition.
const userSchema: Schema<User> = {
type: 'object', // Correct.
fields: {
name: {
type: 'string', // Correct.
default: 'John', // Correct.
},
status: {
type: 'enum', // ERROR!: type "enum" is not assignable to type "string".
enums: ['verified', 'created'], // ERROR!: Type "verified" is not assignable to type "created".
default: 'created' // Correct.
}
}
}
// Create a User object.
const user: User = {
name: 'Smith',
status: 'test', // Expected error: type "test" is not assignable to type "verified" | "created".
}
As you can see, the type: 'enum'
is raising an error because the Enum
output is a string, and the enums: []
also raising an error because the Enum
output a single type.
So I'm stuck here, tried several methods but still no luck. Can you help me with this? Thank you so much!
Here is the full code
CodePudding user response:
The big problem in your code is that TypeScript's type system is largely structural as opposed to nominal. You are not creating a new unique type with the declaration type Enum<T> = T
. All that is doing is giving an alias to an existing type. So Enum<ABC>
is precisely the same type as ABC
; it's just a different name. And type names don't make (much of) a difference in TypeScript. There are techniques to simulate/emulate nominal typing in TypeScript, such as branding, but instead of pursuing them, let's step back and see what your underlying use case is.
It really looks like you're trying to distinguish between the string
type on the one hand, and a type which is a string literal type or a union of such types (which you're calling an "enum" or Enum
) on the other hand. If we have a way to do that, then we don't need to try to "mark" the latter sort of type with a label or brand.
Luckily we can tell these apart, mostly. A union of string literal types is a subtype of string
, but not vice versa. So if your mystery string-like type is some X extends string
, then your conditional type should looks something like string extends X ? "string" : "enum"
.
That's the main change we need to make to your code. It's probably also a good idea not to distribute your conditional types across unions; you want type SchemaThing<"a" | "b">
to keep "a" | "b"
together as a unit and not split it into SchemaThing<"a"> | SchemaThing<"b">
. So any generic type parameters checked by a conditional type will be wrapped in a one-tuple, as recommended, to prevent union distribution. That is, not type Foo<T> = T extends ABC ? DEF : GHI
but type Foo<T> = [T] extends [ABC] ? DEF : GHI
.
So now we have the following code:
type TypeOf<T> = [T] extends [object]
? 'object' : string extends T ? 'string' : 'enum'
type Schema<T> = [T] extends [object]
? ObjectSchema<T>
: string extends T ? BaseSchema<T> : EnumSchema<T>
Let's test it out. Given your User
type (where Enum<ABC>
has been replaced with ABC
):
type User = {
name: string;
status: 'verified' | 'created'
}
Let's examine Schema<User>
. First I'll create an ExpandRecursive<T>
helper type so that the displayed type is fully expanded, as mentioned in How can I see the full expanded contract of a Typescript type?:
type ExpandRecursive<T> =
T extends object ? { [K in keyof T]: ExpandRecursive<T[K]> } : T;
So here's SchemaUser
:
type SchemaUser = ExpandRecursive<Schema<User>>
/* type SchemaUser = {
type: "object";
default?: {
name: string;
status: 'verified' | 'created';
} | undefined;
fields: {
name?: {
type: "string";
default?: string | undefined;
} | undefined;
status?: {
type: "enum";
default?: "verified" | "created" | undefined;
enums: ("verified" | "created")[];
} | undefined;
};
} */
Looks good. The name
property is seen to be a "string"
while the status
property is seen to be an "enum"
. And thus the following assignment:
const userSchema: Schema<User> = {
type: 'object',
fields: {
name: {
type: 'string',
default: 'John',
},
status: {
type: 'enum',
enums: ['verified', 'created'],
default: 'created'
}
}
}
works as desired.