2

The inferred return type of the following function is {[k: string]: number}:

export const dateParts = (
  date: Date,
  timeZone?: string
) => {
  const options = {
    day: "numeric",
    month: "numeric",
    timeZone,
    year: "numeric",
  } as const;
  const formatter = new Intl.DateTimeFormat("en", options);
  const parts = formatter.formatToParts(date);
  return Object.fromEntries(
    parts
      .filter(({ type }) => ["year", "month", "day"].includes(type))
      .map(({ type, value }) => [type, Number(value)])
  );
};

But for the keys of the returned object the only posible options are year, month, and day.

Is there a way to restrain the keys of the returned object to be only those?

I tried explicitly adding the return type as {'year': number, 'month': number, 'day': number} or Record<'year' | 'month' | 'day', number>:

export const dateParts = (
  date: Date,
  timeZone?: string
): { year: number; month: number; day: number } => {

But then it conflicts with what Object.fromEntries is returning:

Type '{ [k: string]: number; }' is missing the following properties from type '{ year: number; month: number; day: number; }': 'year', 'month', 'day' ts(2739)

I haven't been able to figure out how to modify the return type of Object.fromEntries so it matches {'year': number, 'month': number, 'day': number} instead of {[k: string]: number}.

MauricioRobayo
  • 2,207
  • 23
  • 26
  • "*It conflicts with what Object.fromEntries*" - can you post the type error that you get when you use `Object.fromEntries(…) as …` with your preferred return type? – Bergi Apr 26 '21 at 14:30
  • @Bergi casting the returned type with `as` works... but, can we make it infer the correct type instead of casting it? – MauricioRobayo Apr 26 '21 at 14:34
  • I doubt it. Generic `Object` builtins are typed pretty badly in Typescript. Have a look at https://stackoverflow.com/q/59996713/1048572, https://github.com/microsoft/TypeScript/issues/31393 and especially https://github.com/microsoft/TypeScript/issues/35745 – Bergi Apr 26 '21 at 14:38

1 Answers1

3

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

jcalz
  • 264,269
  • 27
  • 359
  • 360