简体   繁体   中英

Dynamically generate return type based on array parameter of objects in TypeScript

I'm trying to define a TypeScript definition like the following:

interface Config {
    fieldName: string
}
function foo<T extends Config>(arr: T[]): Record<T['fieldName'], undefined>
function foo(arr: Config[]): Record<string, undefined> {
  const obj = {}
  _.forEach(arr, entry => {
    obj[entry.fieldName] = undefined
  })

  return obj
}

const result = foo([{fieldName: 'bar'}])
result.baz // This should error since the array argument did not contain an object with a fieldName of "baz".

The above code is highly inspried by Dynamically generate return type based on parameter in TypeScript which is almost exactly what I want to do except my parameter is an array of objects instead of an array of strings.

The problem is that the type of result is Record<string, undefined> when I want it to be Record<'bar', undefined> . I'm pretty sure my return definition of Record<T['fieldName'], undefined> is not right (since T is an array of Config -like objects) but I can't figure out how to specify that correctly with the generic type.

Any help is much appreciated.

The other answers here have identified the issue: TypeScript will tend to widen the inferred type of a string literal value from its string literal type (like "bar" ) to string . There are different ways to tell the compiler not to do this widening, some of which are not covered by the other answers.

One way is for the caller of foo() to annotate or assert the type of "bar" as "bar" and not string . For example:

const annotatedBar: "bar" = "bar";
const resultAnnotated = foo([{ fieldName: annotatedBar }]);
resultAnnotated.baz; // error as desired

const resultAssertedBar = foo([{ fieldName: "bar" as "bar" }]);
resultAssertedBar.baz; // error as desired

TypeScript 3.4 introduced const assertions , a way for the caller of foo() to ask for a narrower type without having to explicitly write the type out:

const resultConstAsserted = foo([{ fieldName: 'bar' } as const]);
resultConstAsserted.baz; // error as desired

But all of those require the caller of foo() to call it in a different way to get the desired non-widening behavior. Ideally foo() 's type signature would be altered in some way so that the desired non-widening behavior just happens automatically when called normally.

Well, the good news is you can do that; the bad news is that the notation for doing this is weird:

declare function foo<
    S extends string, // added type parameter
    T extends { fieldName: S },
    >(arr: T[]): Record<T['fieldName'], undefined>;

First let's make sure that it works:

const result = foo([{ fieldName: "bar" }]);
result.baz; // error as desired

Looks good. The normal call to foo() now outputs Record<"bar", undefined> .

If that seems like magic, I agree. It's almost explainable by saying that adding a new type parameter S extends string hints to the compiler that it should infer a type narrower than just string , and therefore T extends {fieldName: S} will tend to be inferred as a field name with a string literal fieldName . And while T is indeed inferred as {fieldName: "bar"} , the S type parameter is inferred as string and not "bar" . Who knows.

I would love to be able to answer this question with a more obvious or simple way to alter the type signature; maybe something like function foo<T extends { filename: const string}>(...) . In fact a while ago I filed microsoft/TypeScript#30680 to suggest this; so far not much has happened. If you think it would be useful you might want to go there and give it a 👍.


Finally, @kaya3 makes an astute observation: the type signature of foo() really doesn't seem to care about T itself; the only thing it does with T is look up the fieldName value. If this is true for your use case, you can simplify the foo() signature considerably by only caring about S :

declare function foo<S extends string>(arr: { fieldName: S }[]): Record<S, undefined>;

const result = foo([{ fieldName: "bar" }]);
result.baz; // error as desired

And this doesn't even seem like that much black magic, because S is inferred as "bar" .


Okay, hope this helps. Good luck!

Playground link

The problem is that the object literal {fieldName: 'bar'} is inferred as type {fieldName: string} , not the more specific type {fieldName: 'bar'} . Anything you do with the object, such as putting it into an array and passing it to a generic function, will not be able to recover the string literal type 'bar' from its type, because that string literal isn't part of its type in the first place.

One way around this is to construct your object with a generic function instead of an object literal, so that the stricter fieldName property type can be preserved:

function makeObject<T>(s: T): { fieldName: T } {
    return { fieldName: s };
}

const result = foo([ makeObject('bar') ])

// type error: Property 'baz' does not exist on type Record<'bar', undefined>
result.baz

Playground Link

I would propose something like so

interface Config {
    fieldName: string;
}
function foo<T extends Config>(arr: ReadonlyArray<T>) {
    const obj = {} as { [P in T["fieldName"]]: undefined };
    arr.forEach(entry => {
        obj[entry.fieldName] = undefined;
    });

    return obj;
}

const result = foo([{ fieldName: "bar" }, { fieldName: "yo" }] as const);
result.baz; // error

The key bit is to use your array as const to prevent any widening of the type so that the generic foo infers the correct T, in more detail, the type of T is now

{ readonly fieldName: "bar" } | { readonly fieldName: "yo" }

so that T["fieldName"] is

 "bar" | "yo" 

etc.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM