Angular 14 typed forms - initial value for number controls
Asked Answered
D

7

17

Typed forms are great but I wonder how to initially display a form control with type 'number' as empty input field. I would like to keep my controls as nonNullable as they are required in my form but it excludes using null as initial value which was a solution before.

EDIT: Maybe I describe my case. I have several FormControls of type number. I would like initially to display these inputs empty. So the possible solution is to set initial value in FormControl constructor to null. But then the FormControl has type <number | null>. All of them are required. After submiting I need to send that data to server and asigne a FormControl<number | null> value to property of type number which makes me to add explicit casting to for every property even if I know that after submiting they can't be null because of 'required' validator. I case of FormControl<string> casting isn't necessary and I can display empty input by providing "" as inital value. I was wondering if there is some workaround to do that with number.

Disprove answered 17/8, 2022 at 20:15 Comment(0)
S
5

The required validator doesn't disallow the input to have an empty value. It just adds the error to the FormControl, but the value can still be empty. So having <number | null> makes more sense than <number>. When the validation passes, you can, at this point, assert that the value is not null with ! (e.g. form.controls.name.value!). But maybe you should actually have a <string> type (and pass empty string for the initial value), since an HTML input cannot have a numeric value? If you pass a numeric value to <input type="number">, the number will be converted to string under the hood. When you retrieve the value from a form, the value is a string, not a number.

Socialism answered 22/8, 2022 at 20:31 Comment(0)
P
3

Unfortunately it seems there is no proper solution for removing null type from valid number input (with default empty/null), but as mentioned in accepted answer you should not use string type for all otherwise you will need to convert string to number if API expects number type value. As a workaround you can create typed payload object again on submit to get proper type without null like this.

interface PayloadType {
  name: string;
  amount: number;
  total: number;
}

class Component {
  form = this.fb.group({
    name: ['', [Validators.required]],
    amount: this.fb.control<number | null>(null, [Validators.required]),
    total: this.fb.control<number | null>(null, [Validators.required]),
  });

  constructor(private fb: NonNullableFormBuilder) {}

  onSubmit() {
    if(this.form.invalid) {
      return;
    }
    // get form value if form is valid
    const formValue = this.form.getRawValue();

    // here formValue will have number | null type for all numeric fields
    // so we need to create an object again
    const payload: PayloadType = {
      name: formValue.name,
      amount: formValue.amount!,
      total: formValue.total!,
    };
  }
}

this requires creating an object again but I could not find any other way, if angular/ts can infer different type if form is valid then this won't be needed.

Pavlish answered 21/12, 2022 at 12:15 Comment(0)
N
2

Something like this works, assuming your form is always validated before accessing its value you shouldn't have any problems.

myFormControl = new FormControl<number>(
    { value: null! },
    { validators: [Validators.required], nonNullable: true }
);

This way it will appear empty, but the form shouldn't allow submission until it's valid, and you get the correct type of just number.

Noumenon answered 15/3, 2023 at 16:9 Comment(1)
Oh god. This is what I exactly wanted!Idelson
U
1

You could set it to 0 if you don't need to display that number... You could also set a second type undefined or null but it's basically like having nonNullable at false.

Here's an example:

public formControl: FormControl<number | undefined> = new FormControl<number | undefined>(undefined);

There nothing wrong to allow null. In fact you should use it if your initial value is not set. You'll have better intellisense in you html and typescript. It will help you prevent access to undefined variable (ex: user is undefined, so user.name will throw an error, forcing you to use user?.name).

Unarmed answered 17/8, 2022 at 21:14 Comment(4)
Thanks for answer. The point is I don't want to display any number so null would be good but then I need to cast to number all these values after submitting even if I know they can't be null because these fields are required. I was looking for a workaround like using empty string for text inputs (field is empty without null). But with strict typing this could be impossible.Ultraviolet
I'm not sure I understand the last comment, but you should use validators like this : new FormControl<number | null>(null, [Validators.required]). Then when submiting the form, just test the validity of the formGroup like this: formGroup.invalid and then send feedback to the user.Unarmed
That is exactly what I do. My point is that after submit I need to send that data to server and then I have to assigne form control value of type <number | null> to property of type <number>. It makes me to add explicit casting to number to that form control. And I need to do that on every form control even if I know that after submiting they can't be null because of 'required' validator. In case of FormControl<string> and setting nonNullable there is no casting and I can display empty input by providing '' as initial value. That's why I was wondering if there is some workaround.Ultraviolet
Ok, I understand what you mean. I don't know any workaround to this thought. You have to make a choice here. You can set number out of the range you allow, or allow null. So if you don't allow number under 0, you could set your form to -1 and required min of 0.Unarmed
E
0
export interface IResinFeature {
category: FormControl<string | null>;
designBrand: FormControl<string | null>;
quantity: FormControl<number | null>;
dimensionDefault: FormControl<number | null>;
componentType: FormControl<string | null>;
fullsetAttribute: FormControl<string | null>;
style: FormControl<string | null>;
rawMaterial: FormControl<string | null>;

}

export class ListFirstComponent {
  listFirstForm: FormGroup<IResinFeature>;
  constructor(private formBuilder: FormBuilder) {
    this.listFirstForm = this.formBuilder.group({
      category: ['', Validators.required],
      designBrand: ['', [Validators.required, Validators.maxLength(6)]],
      quantity: [null as unknown as number, [Validators.required, Validators.maxLength(3)]],
      dimensionDefault: [null as unknown as number],
      componentType: ['', Validators.required],
      fullsetAttribute: [''],
      style: ['', Validators.required],
      rawMaterial: ['', Validators.required],
    });
  }
}
Ewers answered 1/8, 2023 at 23:48 Comment(1)
null as unknown as number makes me really sadLogwood
F
0

Since NaN is technically a number, Here you go:

import { Directive, ElementRef, HostListener, Input, OnInit, Optional, Self } from '@angular/core';
import { ControlContainer, ControlValueAccessor, FormControl, FormGroup, NG_VALUE_ACCESSOR, NgControl } from '@angular/forms';

@Directive({
  selector: 'input[numberControl], input[type="number"]',
})
export class NumberControlDirective implements ControlValueAccessor, OnInit {
  @Input() nullable?: boolean;

  touched = false;
  disabled = false;

  onChange = (_val: unknown) => {};
  onTouched = () => {};

  private _value?: number | null;

  constructor(private el: ElementRef<HTMLInputElement>, @Self() private ngControl: NgControl) {
    ngControl.valueAccessor = this;
  }

  ngOnInit(): void {
    if (this.nullable === undefined) {
      this.nullable = this._value === null;
    }
  }

  @HostListener('input', ['$event'])
  private onInput($event: InputEvent) {
    console.log($event, this.el.nativeElement.value);

    if (this.el.nativeElement.value === '') {
      this.onChange(this.nullable ? null : NaN);
    } else {
      this.onChange(+this.el.nativeElement.value);
    }
  }

  @HostListener('blur', ['$event'])
  markAsTouched() {
    if (!this.touched) {
      this.onTouched();
      this.touched = true;
    }
  }

  writeValue(value: number) {
    this._value = value;
    this.el.nativeElement.value = Number.isNaN(value) || value == null ? '' : value.toString();
  }

  registerOnChange(onChange: any) {
    this.onChange = onChange;
  }

  registerOnTouched(onTouched: any) {
    this.onTouched = onTouched;
  }

  setDisabledState(disabled: boolean) {
    this.disabled = disabled;
    this.el.nativeElement.disabled = disabled;
  }
}

Basically a custom form control with NG_VALUE_ACCESSOR but as a directive.

Empty value is converted to NaN or null depending on your initial value.

If you actually make a custom control you can use ngModel with getter/setter to control your _value instead of @HostListener.

Don't forget to add new validator to check for NaN since default 'required' treats NaN as valid value.

Now you can use some helper types to auto convert your models to form types like this:

export type Controls<FormValue, Strict = true> = {
  [K in keyof FormValue]-?: FormValue[K] extends Array<unknown>
    ? FormControl<
        Strict extends true
          ? FormValue[K] extends NonNullable<FormValue[K]>
            ? Exclude<FormValue[K], undefined>
            : Exclude<FormValue[K], undefined> | null
          : Exclude<FormValue[K], undefined> | null
      >
    : FormValue[K] extends object
    ? FormGroup<Controls<FormValue[K]>>
    : FormControl<
        Strict extends true
          ? FormValue[K] extends NonNullable<FormValue[K]>
            ? Exclude<FormValue[K], undefined>
            : Exclude<FormValue[K], undefined> | null
          : Exclude<FormValue[K], undefined> | null
      >;
};
new FormGroup<Controls<YourModel>>({...});
Forestforestage answered 26/9, 2023 at 15:3 Comment(0)
U
-1

You can use this.fb.control<number | null>(null) or a cast [null as number] within form builder. For example:

 constructor(private fb: FormBuilder){}

 form = this.fb.group({
    numberField: [null as number],
    numberFieldAlternative: this.fb.control<number | null>(null)
 });
Ulani answered 29/3, 2023 at 13:53 Comment(1)
null as number is not valid TypeScriptBier

© 2022 - 2025 — McMap. All rights reserved.