Extract an object's properties and their generic types within a function's type annotation
Solution 1:
The root of this issue is that your Form
type is not generic, so the compiler does not know any information about the types of fields that are present. One Form
object cannot be distinguished at compile time from any other Form
, so they can't have different behaviour based on the types of fields they hold.
So, let's make Form
generic on the type of the field-containing Record
that it contains.
class Form<T extends Record<string, Field<any>>> {
// Object containing all Field objects in this Form
fields: T
constructor({ fields }: { fields: T}) {
this.fields = fields;
}
}
Now, we use a utility type to extract the RequestData type out of the above record type (effectively, removing the Field wrapper).
type ExtractFieldValues<T extends Record<string, Field<any>>> = {
[K in keyof T]: T[K] extends Field<infer V> ? V : never;
};
We can tweak the submit
function to work with the new types, with one caveat. There will always need to be a type assertion when you're unboxing the values from instances of Field
. The compiler isn't alert enough to analyse any loops you may use to do so and guarantee that every property of the Fields
type has been included:
const submit = async <Response extends unknown, Fields extends Record<string, Field<any>>>(
form: Form<Fields>,
request: (data: ExtractFieldValues<Fields>) => Promise<Response>,
): Promise<Response> => {
const fieldsAsGenerics = {
// Extract value stored in every form.field
} as ExtractFieldValues<Fields>;
// A type assertion will ALWAYS be required here:
// the compiler can't infer that you're looping over every
// property of the form type and extracting T from Field<T>.
return await request(fieldsAsGenerics);
};
To avoid the type assertion, it may be wise to store the values directly in the fields
object without the Field
wrapper, but this might not be possible or useful depending on the rest of your code.
The types can now be inferred correctly in usage (mouseover in the playground link to confirm):
const myForm = new Form({
fields: {
name: new Field<string>(/*Field constructor argsargs*/),
age: new Field<number | null>(/*Field constructor args*/)
}
});
submit(myForm, async (data) => {
// data should be of type { [K in keyof typeof myForm.fields]: typeof myForm.fields[K] }
// which in this case is { name: string; age: number | null }
});
Playground link