ngx-formly icon indicating copy to clipboard operation
ngx-formly copied to clipboard

Add a FormlyFieldConfig builder that supports type safe creation of configs

Open StephenCooper opened this issue 5 years ago • 10 comments

Type safe config

I am working in Typescript with Formly and after setting up many forms I have created custom FormlyFieldConfig builder methods for the common input types. For, example an input, a number input, a date picker and so on.

As we often have a Typescript interface or class representing our form model I looked to see if it was possible to achieve type safety with my config builders. I found that this is possible and it makes for a fantastic developer experience. Retrofitting this type safety highlighted some typos in key values during development.

I have a live example of the type safe FormlyFieldConfig builder on stackblitz here.

https://stackblitz.com/edit/type-safe-formly-builder-interface?file=src/app/formly-builder.ts

Typing key property of FormlyFieldConfig

The code in formly-builder is what I am proposing to be added into formly to help others easily take advantage of type safe config builders.

The core part is the following type definitions.

/** Ensure that the key is a valid property of the model Model and that the type of the value for that property is FieldType */
export type FormlyFieldKeyOfType<Model, FieldType> = {
  [Key in keyof Model]: Model[Key] extends FieldType ? Key & string : never
}[keyof Model];

/** Extension of FormlyFieldConfig with a restricted key type that is a string but also is a valid key of Model and Model[key] is of type FieldType */
export interface FormlyFieldConfigKeyed<Model, FieldType>
  extends FormlyFieldConfig {
  key: FormlyFieldKeyOfType<Model, FieldType>;
}

Creating a FormlyConfigBuilder class

Using these we can then define the FormlyConfigBuilder class for a given Model. This can have a build method with two potential overloads. The first takes a FieldType parameter which then enforces that the type checking for the key against the given model. We could also provide an overload that does not provide a FieldType but this would only check that the key is a property of the Model but would not check the type of that property.

/** Formly type safe config builder for given Model interface.
 */
export class FormlyConfigBuilder<Model> {

  /** fieldConfig must have key property that is a valid keyOf Model and its type equals FieldType */
  public build<FieldType>(
    fieldConfig: FormlyFieldConfigKeyed<Model, FieldType>
  ): FormlyFieldConfig;

  /** fieldConfig must have key property that is a valid keyOf Model */
  public build(
    fieldConfig: FormlyFieldConfigKeyed<Model, any>
  ): FormlyFieldConfig {
    return fieldConfig;
  }
}

Alternative standalone builder function

/** Function to build config which applies keyOf and value type check to config based of the Model and FieldType */
export function buildConfig<Model, FieldType>(
  fieldConfig: FormlyFieldConfigKeyed<Model, FieldType>
): FormlyFieldConfig {
  return fieldConfig;
}

Example use of FormlyConfigBuilder

Given the following interface we could then use our form builder as follows.

export interface FormModel {
  name: string;
  age: number;
  dob: Date;
}

Using this builder we get smart auto complete of only the valid property from the FormModel when we set the type.

fb = new FormlyConfigBuilder<FormModel>();
fields: FormlyFieldConfig[] = [
    this.fb.build<string>({ key: "name" }), 
    this.fb.build<number>({ key: "age" }), 
    this.fb.build<Date>({ key: "dob" }),
];

Expected use via extending FormlyConfigBuilder

With the FormlyConfigBuilder in place it is very easy to then create your own custom config builder as an extension of it. This can then be used to hide the explicit typing.

export class AppFormlyConfigBuilder<T> extends FormlyConfigBuilder<T> {

  number(
    fieldConfig: FormlyFieldConfigKeyed<T, number>
  ): FormlyFieldConfig {
    return this.build<number>({
      type: "input",
      ...fieldConfig,
      templateOptions: { type: "number", ...fieldConfig.templateOptions },
    });
  }
}

This can then be used as follows

fb = new AppFormlyConfigBuilder<FormModel>();
fields: FormlyFieldConfig[] = [
    this.fb.number({ key: "age" }), 
];

Benefits

Type checking of key values to avoid typos and copy and paste errors. Enables auto complete while setting up the form in IDEs which is a huge time saver.

The base class and the type give developers a basis to build their own custom config builders in a type safe manner.

Current limitations

Does not currently support dot chained key values such as address.house for example.

Happy to create a PR for this feature if you think it is worth while.

StephenCooper avatar Oct 28 '20 22:10 StephenCooper

Similar concept requested in https://github.com/ngx-formly/ngx-formly/issues/1850 but issue was self closed after author worked out how to enforce keyOf for a given model. Suggests there may be other devs trying to create their own types for FormlyFieldConfigs already.

StephenCooper avatar Oct 28 '20 22:10 StephenCooper

Thanks, @aitboudad, for the label. Do you have any further thoughts on this issue or know anyone who would like to weigh in? Otherwise should I start work on the PR?

StephenCooper avatar Dec 02 '20 12:12 StephenCooper

Hi @StephenCooper, I'm aware of that issue and it'll be part of v6 for sure. We're going to work on it together, I just need to find some spare time to do some research on that area and I'll let you know the next step :)

aitboudad avatar Dec 02 '20 14:12 aitboudad

I put together an example of how type safe formly builder might work: Formly Builder Would appreciate feedback on the API.

nthonymiller avatar Jan 30 '21 09:01 nthonymiller

I think its time to give this issue what it deserves :), the first step I think is to improve our JSON format:

Step 1

1- templateOptions => ui (or props :thinking:):

{
  key: 'name',
  type: 'input',
  ui: { label: 'Name' },
  expressionProperties: {
    'ui.disabled': 'model.disabled'
  }
}

2- expressionProperties|hideExpression => expressions:

{
  key: 'name',
  type: 'input',
  expressions: {
    hide: 'model.disabled',
  }
}

3- fieldGroup => group:

{
  key: 'address',
  group: [ ... ]
}

4- fieldArray => array:

{
  key: 'addresses',
  array: { ... }
}

let me know if there is more parts to improve!

Step 2

define helpers for each type, that supports type-safe.

Step 3

provide a builder as a sub-package

aitboudad avatar May 18 '21 22:05 aitboudad

@aitboudad I actually follow something like this In our internal wrapper.

I am on vacation atm but if you'd like when I get back I can show you what I've done.

I also created a json-schema that tells options and I'm modifying it based on type given in the config. From this we can generate TS typings.

On Tue, May 18, 2021, 4:45 PM Abdellatif Ait boudad < @.***> wrote:

I think its time to give this issue what it deserves :), the first step I think is to improve our JSON format: Step 1

1- templateOptions => ui (or props 🤔):

{

key: 'name',

type: 'input',

ui: { label: 'Name' },

expressionProperties: {

'ui.disabled': 'model.disabled'

} }

2- expressionProperties|hideExpression => expressions:

{

key: 'name',

type: 'input',

expressions: {

hide: 'model.disabled',

} }

3- fieldGroup => group:

{

key: 'address',

group: [ ... ] }

4- fieldArray => array:

{

key: 'addresses',

array: { ... } }

Step 2

define helpers for each type, that supports type-safe. Step 3

provide a builder as a sub-package

— You are receiving this because you are subscribed to this thread. Reply to this email directly, view it on GitHub https://github.com/ngx-formly/ngx-formly/issues/2571#issuecomment-843615993, or unsubscribe https://github.com/notifications/unsubscribe-auth/ADB4XNJSXW3D5V7EHIS5GWTTOLUY3ANCNFSM4TC5RLKA .

kenisteward avatar May 18 '21 22:05 kenisteward

@aitboudad I agree with improving the JSON format as sometimes I get confused as the format is trying to support Field, Group and Array formats.

This is part of the reason why I wrote a builder that I've been using in my own application to help with this.

Example can be found here: Formly Builder Example

Would be happy to help on this.

nthonymiller avatar May 18 '21 23:05 nthonymiller

Probably similar to what @kenisteward described I have been linking the type to a extended FormlyTemplateOptions to enable the developer to know what template options they can specify.

For example a DatePicker control supports minDate and maxDate.

export interface FormlyDateTemplateOptions extends FormlyTemplateOptions {
  minDate?: Date;
  maxDate?: Date;
}

So being able to provide a custom type to represent the templateOptions / ui properties would be great.

StephenCooper avatar May 19 '21 10:05 StephenCooper

Moved the Step 1 to #2853

@kenisteward Sure

@StephenCooper we'll reach that in Step 2, I've done a similar approach recently I'll share my work once done with step1 :)

@nthonymiller the builder you've created is close to what I've in mind, I'll let you know once reached that part.

aitboudad avatar May 21 '21 12:05 aitboudad

I am creating Type safe builder also, my idea is similar with @StephenCooper. But the builder is not Injectable so I can't use some internal providers. What I can do it create factory to create a builder like below.

  formGroup = new FormGroup({});

  fb = factory.createBuilder<IClaimSubClaimModel>();

  fields: NvFormFieldConfig[] = [
    this.fb.field('ClaimDetail', {
      type: 'nv-dropdown',
      templateOptions: {
        options: this.subClaimStore.select(s => s.detailCasualties)
      }
    })
  ];

Do we have any ideas to make the builder support Injectable and type safe together without factory?

rickynguyen4590 avatar Apr 15 '22 04:04 rickynguyen4590