Home > Software engineering >  Typescript returning an object with specific key value or null
Typescript returning an object with specific key value or null

Time:12-07

I want to be able pass in an object into a function, with that function returning an object with all its values set to null. The return type of the function is the object with each key as either null OR the original type of the value passed in. For example, if I pass in the object {foo: String(), bar: Number()} then the return type of the function should be {foo: string | null; bar: number | null}. Currently, my code is working to the extent that the function infers the types in the object, but not for each specific property. Property "foo" can only be a string or null, but the type lists it as ALL the possible properties in the object, including the type of "bar" and other keys that are passed in. How do I correctly map the types?

function useForm<T extends Record<K, T[K]>, K extends keyof T>(formData: T) {

  // Default value for each key is null
  return (Object.keys(formData) as K[]).reduce((acc: Record<K, T[K] | null>, val) => {
    acc[val] = null
    return acc
  }, {} as Record<K, T[K] | null>)
}

const formData = useForm({foo: String(), bar: Number(), test: ["test1", "test2"] as const})

// Should be string| null, but is string | number | ["test1", "test2"] | null
type fooValue = typeof formData.foo

// Should be string| null, but is string | number | ["test1", "test2"] | null
type barValue = typeof formData.bar

// Should be "test1" | "test2" | null, but is string | number | ["test1", "test2"] | null
type testValue = typeof formData.test

CodePudding user response:

The original call signature

declare function useForm<T extends Record<K, T[K]>, K extends keyof T>(
  formData: T
): Record<K, T[K] | null>

doesn't work for a few reasons. One is that there is no inference site for K; the only place where the compiler could possibly infer K from is the fact that T extends Record<K, T[K]>, but generic constraints like extends Record<K, T[K]> are not consulted when inferring type arguments. There was a suggestion at microsoft/TypeScript#7234, but it was never implemented. So K will always fall back to just keyof T, where T will be inferred from formData. Then, the output type, Record<K, T[K] | null> will be Record<keyof T, T[keyof T] | null>.

The second, bigger reason, is that the output type uses the Record<K, V> utility type in which the property types are independent of the key types. If you have a Record<K, V>, every property will be of type V. So every property will be of type T[keyof T] | null. If you want properties to be different for different keys, you don't want to use Record<K, V>.

Finally, your T[K] | null doesn't do the right thing for arrays, since you want a property like ["a", "b"] to become "a" | "b" | null and not ["a", "b"] | null.


Instead, I'd write it this way:

type ArrayElementOrValue<T> = T extends readonly any[] ? T[number] : T;

function useForm<T extends object>(formData: T) {
  return Object.keys(formData).reduce((acc: any, val) => {
    acc[val] = null
    return acc
  }, {}) as { [K in keyof T]: ArrayElementOrValue<T[K]> | null }
}
type ArrayElementOrValue<T> = T extends readonly any[] ? T[number] : T;

The formData parameter is of generic type T (constrained to object, so formData cannot be of a primitive type like string or number). Let's examine the return type, { [K in keyof T]: ArrayElementOrValue<T[K]> | null }.

This is a mapped type in which each property T is transformed from T[K] to ArrayElementOrValue<T[K]> | null, where ArrayElementOrValue<X> is a conditional utility type that returns its input, or if the input is an array type, returns the element type of its input. (So, for example, ArrayElementOrValue<string> is just string, but ArrayElementOrValue<["a", "b"]> is "a" | "b".)

This behaves the way you wanted:

const formData = useForm({
  foo: String(),
  bar: Number(),
  test: ["test1", "test2"] as const
});

/* const formData: {
    foo: string | null;
    bar: number | null;
    test: "test1" | "test2" | null;
} */

Note that the compiler is not smart enough to verify that your function implementation actually produces a value of that type, so I used a type assertion to just tell the compiler that's what it does.

Playground link to code

  • Related