Home > Software design >  Map deeply nested object return type of a function depending on shape of input parameter
Map deeply nested object return type of a function depending on shape of input parameter

Time:09-13

I've been trying to achieve some complex typing for a library I'm working on, but I'm not even sure what I'm trying to achieve is doable with TypesScript.

The library I'm working on is a schema & promise-based form generator built with Vue & TS, that handles for you UI rendering & mounting, validation, etc., and lets you collect data from users in a purely functional way.

Here's a very basic example for you to understand:


const { formData, isCompleted } = await formApi.createForm({
  fields: [
    {
        "key": "email",
        "label": "Email address",
        "type": "text",
        "required": true,
        "validators": { "email": isEmail, },
        "size": 8
    },
    {
        "key": "password",
        "label": "Password",
        "type": "password",
        "required": true
    },
  ]
})

Here, formData will be equal to: { email: "...", password: "..." }

What I want to achieve is to type FormData to be equal in this case to { email: string, password: string } instead of the { [key: string]: any } type that I currently return.

This is a very simple example, but the library can generate really complex forms, supports deeply nested objects/arrays fields, conditional rendering based on other fields, and a lot of customisation.

EDIT

After seeing replies, I understand that depending on the complexity of the schema, the actual TS implementation will differ a lot.

Considering this, here's a more advanced example that includes a lot of the aspects the lib is able to handle (Live stackblitz example : https://stackblitz.com/edit/vue-xyzwq9?file=src/components/SimpleDependency.vue):


const { isCompleted, formData } = await createForm({
  fullScreen: 'true md:false',
  title: 'ADVANCED FORM SCHEMA EXAMPLE',
  gridSize: 8,
  fieldSize: '8 md:4',
  fields: [
    {
      key: 'firstName',
      label: 'First name',
      type: 'text',
      required: true,
    },
    {
      key: 'lastName',
      label: 'Last name',
      type: 'text',
      required: true,
    },
    {
      key: 'email',
      label: 'Email',
      type: 'text',
      size: 8,
    },
    {
      key: 'address',
      label: 'Address',
      type: 'object',
      size: 8,
      fields: [
        {
          key: 'street',
          label: 'Street',
          type: 'text',
          required: true,
        },
        {
          key: 'zipCode',
          label: 'ZIP Code',
          type: 'text',
          required: true,
        },
        {
          key: 'city',
          label: 'City',
          type: 'text',
          required: true,
        },
        {
          key: 'country',
          label: 'Country',
          type: 'text',
          required: true,
        },
      ],
    },
    {
      key: 'skills',
      label: 'Dev skills',
      type: 'select',
      fieldParams: {
        multiple: true,
        writable: true,
      },
      options: [
        'Vue',
        'Angular',
        'React',
        'ExpressJs',
        'NestJs',
        'Fastify',
        'Typescript',
        'C#',
        'Swift',
        'Go',
        'Rust',
      ].map((value) => ({ label: value, value })),
    },
    {
      key: 'hasProExperiences',
      label: 'Has prior professional experiences',
      type: 'checkbox',
      size: 8,
    },
    {
      key: 'professionalExperiences',
      label: 'Professional experiences',
      type: 'array',
      size: 8,
      dependencies: ['hasProExperiences'],
      condition: ({ hasProExperiences }) => !!hasProExperiences,
      headerTemplate: (_, index) => `JOB ${index   1}`,
      fields: [
        {
          key: 'company',
          label: 'Company',
          type: 'text',
          required: true,
        },
        {
          key: 'job',
          label: 'Job title',
          type: 'text',
          required: true,
        },
        {
          key: 'period',
          label: 'Job period',
          type: 'daterange',
          required: true,
          size: 8,
        },
        {
          key: 'description',
          label: 'Job description',
          type: 'textarea',
          size: 8,
        },
      ],
    },
  ],
});

And from this complex schema, here's the type definition I want to assign to formData in output :

interface ExpectedType {
  firstName: string;
  lastName: string;
  email: string;
  address: {
    street: string;
    zipCode: string;
    city: string;
    country: string
  };
  skills: string[] // "multiple" prop set to true on the field, so multiple values selected
  hasProExperiences: boolean;
  professionalExperiences?: Array<{  // Could be undefined because rendered & included in form state only if 'hasProExperiences' is true
    company: string; 
    job: string;
    period: [string, string];
    description: string;
  }>
}

CodePudding user response:

The solution for this "simple example" could look like this:

async function createForm<T extends string>(arg: {
  fields: { 
    key: T,
    label: string,
    type: string,
    required: boolean,
    size?: number
  }[]
}): Promise<{ formData: { [K in T]: string }, isCompleted: any }> {
  return {} as any
}

We can store the contents of the key field inside the generic type T and use it later to build a mapped type for the return type.

But if the schemas get more complex, this may have to be completely rewritten with more/different generic types.


Playground


EDIT:

Here is a solution for your edited details.

type FormInfo<N> = {
  key: N
  label: string
  type: N
  required?: boolean
  size?: number
  fieldParams?: {
    multiple: boolean,
    writable: boolean,
  },
  condition?: (...args: any) => any
  fields?: FormInfo<N>[]
}

The type FormInfo is used to describe the schema of the createForm argument. I left out properties which were not relevant to the question.

type ResolveFormType<K extends FormInfo<any>> =  
  K["type"] extends "checkbox"
    ? boolean
    : K["type"] extends "object"
      ? K["fields"] extends infer U extends FormInfo<any>[]
        ? FormInfoReturnType<U[number]>
        : never
      : K["type"] extends "select"
        ? K["fieldParams"] extends { multiple: true }
          ? string[]
          : string
        : K["type"] extends "array"
          ? K["fields"] extends infer U extends FormInfo<any>[]
            ? FormInfoReturnType<U[number]>[]
            : never
          : K["type"] extends "daterange"
            ? [string, string]
            : string

ResolveFormType takes an object from the fields and evaluates the type based on the type property.

type FormInfoReturnType<T extends FormInfo<any>> = UnionToIntersection<{
  [K in T as K["condition"] extends (...args: any) => any ? never : K["key"]]: 
    ResolveFormType<K>
} | {
  [K in T as K["condition"] extends (...args: any) => any ? K["key"] : never]?: 
    ResolveFormType<K>
}>

FormInfoReturnType recursively goes through the fields and determines which properties should be optional.

type UnionToIntersection<U> = 
  (U extends any ? (k: U)=>void : never) extends ((k: infer I)=>void) ? I : never

type ExpandRecursively<T> = T extends object
  ? T extends infer O ? { [K in keyof O]: ExpandRecursively<O[K]> } : never
  : T;
type Narrowable = string | number | boolean | symbol | object | undefined | void | null | {};

async function createForm<T extends FormInfo<N>, N extends Narrowable>(arg: {
  fields: T[]
}): Promise<{ isCompleted: any, formData: ExpandRecursively<FormInfoReturnType<T>> } > {
  return {} as any
}

Playground

CodePudding user response:

Here's my take on a solution, although the one by @TobiasS works as well. It's quite an eyesore but it was designed to be more "extensible".

type InputTypeMap = {
    text: string;
    textarea: string;
    daterange: [number, number];
    checkbox: boolean;
};

type AddUndefinedIfDependent<T, F extends FormField> = "dependencies" extends keyof F ? T | undefined : T;

type DataFrom<F extends FormField[]> = {
    [K in F[number]["key"]]:
        Extract<F[number], { key: K }> extends infer Field extends FormField
            ? Field["type"] extends keyof InputTypeMap
                ? AddUndefinedIfDependent<InputTypeMap[Field["type"]], Field>
                : Field["type"] extends "object"
                    ? "fields" extends keyof Field
                        ? Field["fields"] extends FormField[]
                            ? AddUndefinedIfDependent<DataFrom<Field["fields"]>, Field>
                            : never
                        : never
                    : Field["type"] extends "array"
                        ? "fields" extends keyof Field
                            ? Field["fields"] extends FormField[]
                                ? AddUndefinedIfDependent<DataFrom<Field["fields"]>[], Field>
                                : never
                            : never
                        : Field["type"] extends "select"
                            ? "options" extends keyof Field
                                ? Field["options"] extends { value: any }[]
                                    ? "fieldParams" extends keyof Field
                                        ? Field["fieldParams"] extends { multiple: true }
                                            ? AddUndefinedIfDependent<Field["options"][number]["value"][], Field>
                                            : AddUndefinedIfDependent<Field["options"][number]["value"], Field>
                                        : never
                                    : never
                                : never
                            : never
            : never;
} extends infer O ? { [K in keyof O]: O[K] } : never;

Essentially it's just logic to get the correct type out. What's different is the use of another type to map input types to the output type. For example, daterange outputs [number, number] in InputTypeMap. This makes it easy to add more input types if you need to. Note that types that are affected by other things in the field should go in the DataFrom type.

If you're wondering about this line here:

} extends infer O ? { [K in keyof O]: O[K] } : never;

It's to simplify the output type so you don't get DataFrom<...> in tooltips.

And here's how you would define your function:

function createForm<I extends FormInit>(
    init: Narrow<I>
): Promise<{
    isCompleted: false;
    formData: undefined;
} | { // using discriminated union so that when isCompleted is false, formData is undefined
    isCompleted: true;
    formData: DataFrom<I["fields"]>;
}> {

Playground

  • Related