I have a form which has three separate instances, but at its core has the same data with one or two different properties. I wanted to have a base form class which takes a generic argument to narrow the types and remove some controls where they are not needed, but I have been fighting Typescript for hours and am close to giving up and just using untyped forms.
I have created a smaller example to show the problem:
export type FormOne = {
name: AbstractControl<string | null>;
email: AbstractControl<string | null>;
age: AbstractControl<number | null>;
};
export type FormTwo = {
name: AbstractControl<string | null>;
email: AbstractControl<string | null>;
};
export type FormThree = {
name: AbstractControl<string | null>;
email: AbstractControl<string | null>;
location: AbstractControl<string | null>;
};
export type BaseForm = FormOne | FormTwo | FormThree;
I wanted to define my base class like so:
@Directive()
export abstract class BaseFormComponent<T extends BaseForm> {
private readonly _formBuilder = inject(FormBuilder);
public readonly baseForm = this._formBuilder.group<T>({
name: this._formBuilder.control<string | null>(''),
email: this._formBuilder.control<string | null>(''),
age: this._formBuilder.control<number | null>(0),
location: this._formBuilder.control<string | null>(''),
});
}
but come across the TS2345
error:
TS2345: Argument of type
{
name: FormControl<string | null>;
email: FormControl<string | null>;
age: FormControl<number | null>;
location: FormControl<string | null>;
}
is not assignable to parameter of type T
{
name: FormControl<string | null>;
email: FormControl<string | null>;
age: FormControl<number | null>;
location: FormControl<string | null>;
}
is assignable to the constraint of type T, but T could
be instantiated with a different subtype of constraint BaseForm.
This confuses me because there are no other subtypes of BaseForm than the three I have defined, and my understanding of union types is that BaseForm
would resolve to:
{
name: AbstractControl<string | null>;
email: AbstractControl<string | null>;
age?: AbstractControl<number | null>;
location?: AbstractControl<string | null>;
}
which should allow the base form as I defined it in the abstract class. The end goal would be to have components like:
@Component({...})
export class FormTwoComponent extends BaseFormComponent<FormTwo> implements OnInit {
public ngOnInit(): void {
this.baseForm.removeControl('age');
this.baseForm.removeControl('location');
}
}
Now I realise that the component above has a form which doesn't conform to the given T
until ngOnInit
runs, and so this might cause compile-time problems of its own, but I haven't even got so far as defining the base class.
Can anyone more versed in Typescript explain to me how I am going wrong, and how this could be done, if at all?
StackBlitz project containing all code shown.
To anyone coming across this, I am not sure it is possible, nor would it achieve what I wanted originally.
What I settled on was a custom form builder, which, while ugly, does allow me to create a typed form with certain properties on some forms and not on others.
Here is an example of the code:
export class TypedFormBuilder<T extends Record<string, any> = {}> {
private readonly _formBuilder = inject(FormBuilder);
private readonly _form: FormGroup = this._formBuilder.group({}) as unknown as FormGroup<T>;
public withName(name?: string) {
this._form.addControl('name', this._formBuilder.control(name));
return this as unknown as TypedFormBuilder<T & { name: string }>;
}
public withAge(age?: number) {
this._form.addControl('age', this._formBuilder.control(age));
return this as unknown as TypedFormBuilder<T & { age: number }>;
}
public build(): FormControl<T> {
return this._form;
}
}
const builder = new TypedFormBuilder(); // TypedFormBuilder<{}>
const form = builder
.withName('John') // TypedFormBuilder<{name: string}>
.withAge(32) // TypedFormBuilder<{name: string} & {age: number}>
.build(); //FormGroup<{name: string} & {age: number}>
form.controls. // intellisense suggests the controls
form.value // {name: 'John', age: 32}
You can create just one generic withControl
method and still get the typed form:
public withControl<K extends string, V, NN extends boolean = false>(
key: K,
value: V,
options: { nonNullable?: NN, validators?: ValidatorFn[] } = {}
) {
const { nonNullable, validators } = options;
this._form.addControl(key, this._formBuilder.control(value, {
nonNullable,
validators
}));
return this as unknown as TypedFormBuilder<T & { [key in K]: NN extends true ? V : V | null }>;
}
const form = builder
.withControl('name', 'John', { nonNullable: true })
.withControl('age', 32, { validators: [Validators.max(99)] })
.build(); // FormGroup<{name: string} & {age: number | null}>
but personally I prefer the explicit withName
, withAge
, etc. as a fair few of my controls will need custom validators.
Some may call this lunacy, some may call it cheating because I have to use unknown
, but I don't care :)