3

I'm trying to write a TS function which gets a nested value from given object. That object can be one of several types so I'm using generics. However, TS complains so I feel like I'm misunderstanding how generics work in TS:

interface BaseValueObject<T> {
  value: T | null
}

type StringValue = BaseValueObject<string>
type NumberValue = BaseValueObject<number>

interface FormAData {
  name: StringValue,
  age: NumberValue
}

interface FormBData {
  height: NumberValue
  nickname: StringValue
}

interface FormA {
  data: FormAData
}

interface FormB {
  data: FormBData
}

type Form = FormA | FormB

const getFormValue =
  <F extends Form, P extends keyof F['data']>(form: F, property: P) =>
    form['data'][property]['value'] // Type 'P' cannot be used to index type 'FormAData | FormBData'

Desired usage:

const formARecord: FormA = {
  data: {
    name: {
      value: 'Joe'
    },
    age: {
      value: 50
    }
  }
}
const joesAge = getFormValue(formARecord, 'age')
console.log(joesAge) // 50

Playground

Solution

Here's what I ended up doing, similar to what @jered suggested in his answer:

playground

Basically the lesson is to make explicit any invariants you have your typings. In my case, I wasn't formally telling the compiler that every property FormAData and FormBData adhered to the same interface. I was able to do so by extending them from this base interface:

...
type Value = StringValue | NumberValue

interface BaseFormData {
  [property: string]: Value
}

interface FormAData extends BaseFormData {
  ...
0

1 Answer 1

1

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"];
}
Sign up to request clarification or add additional context in comments.

2 Comments

I ended up doing almost exactly what you wrote in your edit ^ ``` type Value = StringValue | NumberValue interface BaseFormData { [property: string]: Value } interface FormAData extends BaseFormData { ... ``` Thank you! Yeah the general lessons seems to be: formally enforce an invariant in the type system if possible. In this case, every "data object" property had the same signature but I needed to tell the compiler that explicitly. Thank you!
@VanceFaulkner cool, would you be willing to drop a link to a Playground example with more detail on your solution? Curious to see what you came up with.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.