c.m
c.m

Reputation: 133

Deriving associated interfaces from dynamic typed model

This is a follow up to this question: Best way to get dynamic type definition in typescript from a configuration object

Summary of previous answer

So to summarize above this works to properly create a dynamic type for the model instance given a configuration object. Our stack currently we can cast models that come in as type agnostic json blobs to appropriate types while also wanting static type checking. This solution works great.

aka

Defining a dynamic typed model
class Person extends ModelFromAttributes({
  joined: Date,
  name: String,
  first_name: String,
  last_name: String,
  age: Number,
  skills: [String],
  locale: (v: any) => (v === 'vacation' ? null : String),
  pets: [Pet],
}) {}
Instantiation and intellisensed Result
// bill's properties and types will be properly intellisensed

// bill.joined; // (property) joined?: Date | undefined
// bill.locale; // (property) locale?: string | null | undefined
// bill.name; // (property) name?: string | undefined
// etc..

const bill = new Person({
  joined: '2021-02-10',
  first_name: 'bill',
  last_name: 'smith',
  name: 'bill smith',
  age: '26',
  skills: ['python', 'dogwalking', 500],
  locale: 'vacation',
  pets: [
    {
      name: 'rufus',
      type: 'dog',
    },
  ],
});

New question: Getting other associated interfaces?

So above works great but often i will export the model from its instance to plain object to pass around to components. This is the model's value but essentially without methods and just the raw properties (flattened). On top of this payload might need to be converted to different casing, so im wondering if possible to use similar strategy / pass in the types to the base model class to not have to repeat myself with non-dry interface definitions since came this far already?

Here is a stackblitz with below: https://stackblitz.com/edit/cejkqp?file=index.ts&view=editor

Example of additional interfaces for above example
// get below but inferred from dynamic type object?

interface FlattenedObject {
  joined?: Date;
  name?: String;
  first_name?: String;
  last_name?: String;
  age?: Number;
  skills?: [String];
  locale?: null | String;
  pets?: {
    name?: String;
    type?: String;
  }[];
}

interface SnakeCasedFlattenedObject {
  joined?: Date;
  name?: String;
  firstName?: String;
  lastName?: String;
  age?: Number;
  skills?: [String];
  locale?: null | String;
  pets?: {
    name?: String;
    type?: String;
  }[];
}
class Model {
  // ...

  toFlattenedObject(): Partial<FlattenedObject> {
    return {}; // assume would return flattened plain object version
  }

  toSnakeCasedFlattenedObect(): Partial<SnakeCasedFlattenedObject> {
    return {}; // assume would return flattened plain object version
  }

  // ...
}
Ideal intellisense

Ideally i could get intellisense and type checking for the result value of above but similarly dynamically like the original solution, while also flattening out the sub types. I know maybe this is unconventional, but was just wondering if it is possible with some wizardry.

// bill.toFlattenedObject().joined; // (property) joined?: Date | undefined
// bill.toFlattenedObject().age; // (property) number?: Date | undefined
// bill.toFlattenedObject().pets[0]?.name; // (property) name?: String | undefined

Upvotes: 1

Views: 87

Answers (2)

c.m
c.m

Reputation: 133

So Josh Kelley's first answer definitely worked for flattening, and I was able to figure out the casing as well, and made the 2 main generics a little more dry. So I decided to post this updated answer. this seems to work:

Typescript Playground

The main type definitions were modified as follows

/*********************************
 * Modified MFA with flat option  *
 *********************************/

type MFA<T, C extends 'flat' | 'normal'= 'normal'> = T extends typeof Date
  ? Date
  : T extends typeof String
  ? string
  : T extends typeof Number
  ? number
  : T extends typeof Boolean
  ? boolean
  : T extends abstract new (...args: any) => infer R
  ? C extends 'flat' ? (R extends { publicAttributes: infer S }
    ? MFA<S, C> 
    : R)
  : R
  : T extends (...args: any) => infer R
  ? MFA<R, C>
  : T extends object
  ? { [K in keyof T]: MFA<T[K], C> }
  : T;

/*********************************
 * Casing convert Generics (deep) *
 *********************************/

type CamelCase<S extends string> =
  S extends `${infer P1}_${infer P2}${infer P3}`
    ? `${Lowercase<P1>}${Uppercase<P2>}${CamelCase<P3>}`
    : Lowercase<S>;

type SnakeCase<S extends string> = S extends `${infer T}${infer U}`
  ? `${T extends Capitalize<T> ? '_' : ''}${Lowercase<T>}${SnakeCase<U>}`
  : S;

type Primitive = Date | string | number | boolean | null | undefined;

type CaseConvert<T, C extends 'camel' | 'snake'> =  T extends Array<any>
  ? { [K in keyof T]: CaseConvert<T[K], C> }
  : T extends Primitive  ? T
  : T extends object
  ? {
      [K in keyof T as C extends 'camel' | 'snake' & 'camel' ? CamelCase<string & K> : SnakeCase<string & K>]: CaseConvert<T[K], C>;
    }
  : T;

type KeysToCamelCase<T> = T extends Array<any>
  ? { [K in keyof T]: KeysToCamelCase<T[K]> }
  : T extends Primitive  ? T
  : T extends object
  ? {
      [K in keyof T as CamelCase<string & K>]: KeysToCamelCase<T[K]>;
    }
  : T;

type KeysToSnakeCase<T> = T extends Array<any>
  ? { [K in keyof T]: KeysToCamelCase<T[K]> }
  : T extends object
  ? {
      [K in keyof T as SnakeCase<string & K>]: KeysToSnakeCase<T[K]>;
    }
  : T;

and then this is defined in the main definition that extends the model

type ModelFromAttributes<T extends object> = { 
  publicAttributes: T;
  toFlattenedObject(to: 'snake'): CaseConvert<MFA<T, 'flat'>, 'snake'>; 
  toFlattenedObject(to: 'camel'): CaseConvert<MFA<T, 'flat'>, 'camel'>;
} & Model &
  Partial<MFA<T>>;

Usage to gain appropriate intellisense would be

enter image description here

Upvotes: 2

Josh Kelley
Josh Kelley

Reputation: 58352

Similar to how your ModelFromAttributes type declares publicAttributes, it can declare a toFlattenedObject with the appropriate type.

TypeScript Playground

A snake case type is harder. TypeScript's template string literals can do some string manipulation. Based on GitHub discussions (see the comment thread starting here), it may be possible to convert from camelCase to snake_case, but it would require some wizardry with generics (and may negatively impact your compile times).

Upvotes: 1

Related Questions