1

Question

How can I extract an object's properties and their generic types within a function's type annotation?

I need to be able to infer the type RequestData, so that type propagates properly to the request callback.

Currently when invoking this function, I have to write my request callback with type assertions. I would like my IDE to infer the type of data based on the Form object being passed in.

Also, any advice on how to get rid of the any in my example is appreciated.

Thanks!

Example

class Field<T> {
  value: T;

  constructor(value: T) {
    this.value = value;
  }
}

class Form {
  // Object containing all Field objects in this Form
  fields: Record<string, Field<any>>;

  constructor({ fields }: { fields: Record<string, Field<any>>; ) {
    this.fields = fields;
  }
}



const submit = async <Response extends unknown, RequestData extends Record<string, unknown>>(
  form: Form,
  request: (data: RequestData) => Promise<Response>,
): Promise<Response> => {
  const fieldsAsGenerics = {} as RequestData; // Extract value stored in every form.field

  return await request(fieldsAsGenerics);
};



const myForm = new Form({
   fields: {
    name: new Field<string>('Charlie'),
    age: new Field<number | null>(66),
  }
});

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 }
  // Actual type is unknown
});

Playground Link

See also

Mathilda
  • 437
  • 3
  • 11
  • 2
    For Typescript questions, you might want to consider linking to a [Typescript playground](https://www.typescriptlang.org/play). It becomes a lot faster for answerers to run your code :) – Mack Jan 13 '22 at 05:47
  • Thanks @Mack! I just added the playground link – Mathilda Jan 13 '22 at 05:59

1 Answers1

3

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

Wai Ha Lee
  • 8,598
  • 83
  • 57
  • 92
Mack
  • 691
  • 3
  • 7
  • You're a lifesaver :D This works perfectly. You are correct that I cannot remove the Field wrapper in my situation, because that class holds a lot more than just the value. – Mathilda Jan 13 '22 at 06:47
  • I don't mind keeping the type assertion `as ExtractFieldValues;`. That is far less frustrating than having to assert the type of each property on the `data` object. – Mathilda Jan 13 '22 at 06:51