user11628429
user11628429

Reputation: 15

How can I create a type from a schema that describes it?

For my own learning I'm trying to create something like an ODM, but I'm having a lot of trouble figuring out how to create type definitions from a schema describing a type of document. For some reason type checking for instance isn't working and suggestions don't work (instance seems to act as if it's any so the editor doesn't know what properties it has). Any help would be greatly appreciated, thank you.

type PropertyType = 'string' | 'number';

interface RequiredProperty<T> {
    default: T;
    optional?: false;
}

interface OptionalProperty<T> {
    default?: T;
    optional: true;
}

type BaseProperty<TKind extends PropertyType, TType> = (
    | RequiredProperty<TType>
    | OptionalProperty<TType>
) & {
    kind: TKind;
};

type StringType = BaseProperty<'string', string>;
type NumberType = BaseProperty<'number', number>;

type ModelProperty = StringType | NumberType;

interface Model {
    [x: string]: ModelProperty;
}

type ModelInstance<T extends Model> = {
    [K in keyof T]: T[K]['kind'] extends 'string' ? string : number;
};

const model: Model = {
    str: {
        kind: 'string',
        default: 'abc'
    },
    num: {
        kind: 'number',
        optional: true
    }
};

const instance: ModelInstance<typeof model> = {
    // Type 'string' is not assignable to type 'number'.
    str: 'test',
    num: 123
}

I'd want ModelInstance<typeof model> to be something like:

interface Expected {
    str: string;
    num?: number;
}

Upvotes: 1

Views: 176

Answers (1)

You have two options to handle it.

First one


/**
 * Please refer to this link for explanation
 * https://stackoverflow.com/a/50375286
 */
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;

type PropertyType = 'string' | 'number';


interface RequiredProperty<T> {
  default: T;
  optional?: false;
}

interface OptionalProperty<T> {
  default?: T;
  optional: true;
}

type BaseProperty<Kind extends PropertyType, Type> = (
  | RequiredProperty<Type>
  | OptionalProperty<Type>
) & {
  kind: Kind;
};

type StringType = BaseProperty<'string', string>;
type NumberType = BaseProperty<'number', number>;

type ModelProperty = StringType | NumberType;

interface Model {
  [x: string]: ModelProperty;
}

type Values<T> = T[keyof T]

/**
 * Translates string type name to actual type
 * Logic is pretty straitforward
 * - if ['kind'] is 'string' -> string
 * - if ['kind'] is 'number' -> number
 */
type TranslateType<T extends { kind: PropertyType }> =
  T['kind'] extends 'string'
  ? string
  : T['kind'] extends 'number'
  ? number
  : never;


type GenerateData<T extends Model> =
  /**
   * Iterate throus model data structure
   */
  {
    /**
     * If ['optional'] exists and it is true
     * Clone same data structure {kind:string, default:string}
     * into nested property, make it partial and translate 'string' to string
     */
    [K in keyof T]: T[K] extends { optional: true } ? {
      -readonly [P in K]?: TranslateType<T[K]>
    } : {
      /**
       * Do same as above but without making data optional
       */
      -readonly [P in K]: TranslateType<T[K]>
    }
  };

/**
 * UnionToIntersection -> converts union to UnionToIntersection
 * Values -> obtain all nested properties as a union
 */
type ModelInstance<T extends Model> =
  UnionToIntersection<Values<GenerateData<T>>>

const model = {
  str: {
    kind: 'string',
    default: 'abc'
  },
  num: {
    kind: 'number',
    optional: true
  }
} as const;

type Result = ModelInstance<typeof model>

Playground

Second

interface OptionRequired {
  type: 'string' | 'number'
  optional: boolean
}
interface OptionPartial {
  type: 'string' | 'number'
}

type Option = OptionPartial | OptionRequired

/**
 * Translates string type name to actual type
 * Logic is pretty straitforward
 */
type TranslateType<T extends Option> =
  T['type'] extends 'string'
  ? string
  : T['type'] extends 'number'
  ? number
  : never;

/**
 * Check if optional exists
 * if false - apply never, because union of T|never produces t
 * if true - apply undefined
 */
type ModifierType<T extends Option> =
  T extends { optional: true }
  ? undefined
  : never

/**
 * Apply TranslateType 'string' -> string
 * Apply ModifierType {optional:true} -> undefined
 */
type TypeMapping<T extends Option> = TranslateType<T> | ModifierType<T>

/**
 * Apply all conditions to each option
 */
type Mapping<T> = T extends Record<string, Option> ? {
  -readonly [Prop in keyof T]: TypeMapping<T[Prop]>
} : never

type Data<Options> = Mapping<Options>

const model = {
  a: {
    type: 'string',
    optional: true,
  },
  b: {
    type: 'number',
    optional: false
  },
  c: {
    type: 'string',
  },
} as const

type Result = Data<typeof model>

declare var x:Result

type a = typeof x.a // string | undefined
type b = typeof x.b // number
type c = typeof x.c // string

Playground

Personaly, I think that it is better to use required property instead of optional and accordingly reverse the boolean flags.

I mean to use {required:true} instead of {optional: false}.

It is more readable, but again, it is only my opinion.

Please take a look at this question. This case is VERY similar to yours

Upvotes: 2

Related Questions