Unfortunately, the compiler cannot follow your logic at the type-level strongly enough to give you the types you're looking for. At each step along the way: filter()
, map()
, and Object.fromEntries()
, the compiler produces a correct but too-wide-to-be-useful type.
Ultimately you're going to want to use a type assertion like this:
return Object.fromEntries(
parts
.filter(({ type }) => ["year", "month", "day"].includes(type))
.map(({ type, value }) => [type, Number(value)])
) as Record<'year' | 'month' | 'day', number>;
This is a reasonable approach for situations, like this one, where you know more than the compiler about the type of a value.
The alternative is to provide your own more specific typings for/to filter()
, map()
, and Object.fromEntries()
, which are likely to be useful only for this specific line of code, and have their own caveats.
For example, the compiler does not realize that ({type} => ["year", "month", "day"].includes(type))
acts as a type guard on its input. Type guard functions are not inferred automatically; you would have to annotate it yourself as a user-defined type guard function, like this:
const filteredParts = parts
.filter((part): part is { type: "year" | "month" | "day", value: string } =>
["year", "month", "day"].includes(part.type))
/* const filteredParts: {
type: "year" | "month" | "day";
value: string;
}[] */
Now the output of filter()
will be narrow enough. The caveat here is that the compiler just believes you that a user-defined type guard does what it says it's doing, so it's like a type assertion. Had you written ["foo", "bar", "baz"].includes(part.type)
, instead, the compiler wouldn't notice or warn you.
Then, when you return an array literal inside map()
like [key, val]
, the compiler will widen it to an unordered array type like Array<typeof key | typeof val>
, whereas you specifically need a tuple type like [typeof key, typeof val]
. So you'll need to change that behavior, possibly by using a const
assertion:
const mappedParts = filteredParts.map(
({ type, value }) => [type, Number(value)] as const
);
// const mappedParts: (readonly ["year" | "month" | "day", number])[]
Great, now you have something to pass to Object.fromEntries()
. Unfortunately, the TypeScript standard library's type definitions for Object.fromEntries()
just returns either any
, or something with a string
index signature.
If you want, you could merge in your own type signature that returns a key-remapped type which more accurately represents things (and it will have to be a global augmentation if you are using modules), like this:
interface ObjectConstructor {
fromEntries<T extends readonly [PropertyKey, any]>(
entries: Iterable<T>
): { [K in T as T[0]]: T[1] };
}
And then, finally, your return value will be strongly typed the way you want:
const ret = Object.fromEntries(mappedParts);
/* const ret: {
year: number;
month: number;
day: number;
} */
Is it worth all that? I doubt it. It's up to you though.
Playground link to code