1

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

https://tsplay.dev/w23qrN

  • Could you make the code example more minimal? Something like `type Enum = T;` doesn't seem to be doing anything other than adding a layer of indirection. If you can provide the smallest amount of code necessary to demonstrate the issue it will be easier for others to debug. – jcalz Sep 10 '22 at 20:31
  • Oh, or maybe your problem is that you think `type Enum = T` is doing something... TypeScript's type system is structural, not nominal, so `Enum` and `XYZ` are identical types with your definition; there's no principled way to tell them apart. So `T extends Enum ? A : B` is the same as `T extends unknown ? A : B` which is essentially always going to be `A` (neglecting issues with distributive conditional types). If you're trying to make `Enum` act like some sort of nominal type, you'll need to modify it. – jcalz Sep 10 '22 at 20:34
  • @jcalz Yes, that's what I need to figure out. Let me edit the question with minimal example. Thanks! – Nanang Mahdaen El-Agung Sep 10 '22 at 20:48
  • Does [this approach](https://tsplay.dev/Wo5rgN) meet your needs? If so I could write up an answer explaining although I had to make a significant number of changes to fix multiple issues in your code, most of which are unrelated to each other except that they all appear in your code. Sort of makes the scope wider than I'd like to see for a SO question. For example, your `ObjectSchema` is not recursive, so it will not become a schema containing schemas. Is that the focus of the question? Or ... – jcalz Sep 10 '22 at 20:53
  • ... is it the fact that `Enum` is a no-op type and you are trying to distinguish string literals from strings? I'd love to see a more focused question talking about one aspect of your issue, but if that doesn't happen then I could write up an answer without going into too much detail about the different pieces, otherwise my answer will be book-length – jcalz Sep 10 '22 at 20:54
  • Yes, it seems your approach can fix the issue. Can you please post an answer so I can approve it? And about the `ObjectSchema` fields, sorry it should be `{ [K in keyof T]?: Schema }` because I need it to recursive. Thanks for your help! – Nanang Mahdaen El-Agung Sep 10 '22 at 21:02
  • @jcalz This is the [full code](https://tsplay.dev/w23qrN). If you can check would be really appreciate it. Thanks! – Nanang Mahdaen El-Agung Sep 10 '22 at 22:02
  • No, I'll be writing up an answer to the question with the [mre] and then you can hopefully apply it to your full code; I'm not really interested in wading through larger code examples, at least not at the moment. – jcalz Sep 10 '22 at 22:03
  • I might not get to writing up an answer today (it's getting late in my time zone), but I'll do so when I get a chance. – jcalz Sep 11 '22 at 01:51

1 Answers1

1

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.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360