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.
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
}
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"]>;
}> {