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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With