You should extend your declaration of generics to the "form" interfaces themselves.
In this case you need to give TypeScript a way to "infer" what the type of the data property of the form will be, in order for property to properly index it.
The way you have it written currently gives an error because you can't use keyof to extract the properties of a union type. Consider this example:
type Foo = {
fooProperty: string;
}
type Bar = {
barProperty: string;
}
type Baz = Foo | Bar;
type Qux = keyof Baz; // type Qux = never
What type is Qux supposed to be? It can't be the keys of two different types simultaneously, so it ends up being of type never which is not very useful for indexing properties of an object.
Instead, consider if your base Form type was itself a generic, that you know should always have a data property, but which the specific type of that data property is unknown until it is actually utilized. Fortunately, you could still constrain some aspects of data to ensure enforcements of its structure across your app:
interface FormDataType {
[key: string]: StringValue | NumberValue;
};
interface Form<T extends FormDataType> {
data: T
};
Then when you write your flavors of Form that have more specific type definitions for the data property, you can do so like this:
type FormA = Form<{
name: StringValue,
age: NumberValue
}>;
type FormB = Form<{
height: NumberValue
nickname: StringValue
}>;
In a way this is sort of like "extending" the type, but in a way that allows TypeScript to use the Form generic to infer (literally) the type of data later on.
Now we can rewrite the getFormValue() function's generic types to match the Form generics in the function signature. Ideally the return type of the function would be perfectly inferred just from the function parameters and function body, but in this case I didn't find a good way to structure the generics so that everything was seamlessly inferred. Instead, we can directly cast the return type of the function. This has the benefit of 1. still checking that form["data"] exists and matches the FormDataType structure we established earlier, and 2. inferring the actual type of the value returned from calling getFormValue(), increasing your overall type checking confidence.
const getFormValue = <
F extends Form<FormDataType>,
P extends keyof F["data"]
>(
form: F,
property: P
) => {
return form["data"][property].value as F["data"][P]["value"];
}
Playground
Edit: on further reflection the generics of the Form interface itself is not really necessary, you could do something else like declare a basic Form interface and then extend it with each specific form:
interface FormDataType {
[key: string]: StringValue | NumberValue;
}
interface Form {
data: FormDataType
};
interface FormA extends Form {
data: {
name: StringValue;
age: NumberValue;
}
};
interface FormB extends Form {
data: {
height: NumberValue;
nickname: StringValue;
}
};
const getFormValue = <
F extends Form,
P extends keyof F["data"]
>(
form: F,
property: P
) => {
return form["data"][property].value as F["data"][P]["value"];
}