Home > Software engineering >  Typescript: Switch a dynamic conditional type from an Array
Typescript: Switch a dynamic conditional type from an Array

Time:09-12

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

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.

Playground link to code

  • Related