Home > Net >  Infer object type from object of objects
Infer object type from object of objects

Time:09-12

I don't really know how to describe this, so I will demonstrate it to the best of my ability:

type MetaObject <SomeObject> = {
  categoryOne: {
    [ key in keyof SomeObject ]?: (this: SomeObject) => string;
  };

  categoryTwo?: {
    [ key in keyof SomeObject ]?: SomeObject[key];
  };

  categoryThree?: {
    [ key in keyof SomeObject ]?: number;
  };
};

export const returnSomeObjectBasedOnMetaObject: <T> (metaObject: MetaObject<T>) => T;

I want returnSomeObjectBasedOnMetaObject to be able to infer at least the keys of the returned object, even if it lacks information about the values, based on the MetaObject provided to the function, but it seems unable to do this. For instance,

const someObject = returnSomeObjectBasedOnMetaObject({
  categoryOne?: {
    a: () => 'hello',
    b: () => 'world',
  },

  categoryTwo: {
    b: 'a value',
    c: 'another value'
    d: 4346364
  },

  categoryThree: {
    x: 2,
    y: 75,
    z: 900
  }
});

TS only infers the type of SomeObject based on the object provided to categoryOne, so it ends up as

{
  a: unknown;
  b: unknown;
}

ignoring everything else, including the definition that would provide an actual value type in categoryTwo.

Is there a way I can get the interpreter to consider all the sub-objects when inferring the type of SomeObject?

CodePudding user response:

When there are multiple inference sites for a generic type parameter, such as the different appearances of T in the expanded definition of the metaObject parameter's type in returnSomeObjectBasedOnMetaObject, the compiler uses heuristics to determine how to infer T. It will assign each inference site a "priority", and infer from the highest-priority site or sites. It usually does not synthesize union types or intersection types by combining the various candidates. Instead it infers T from some of them and checks it against the remaining ones. This is often what people want to see, and what it's doing in your case... but sometimes it isn't what people want.

There are various GitHub issues asking for ways to annotate call signatures to give developers more control over this inference, such as microsoft/TypeScript#14829 to block inference from some sites, or microsoft/TypeScript#44312 to allow unions. In your case it looks like you'd want an intersection (because the keyof type operator is contravariant). There's no direct support for this kind of thing, though.


Instead, if you want more control over inference, you should rewrite your call signatures to give you more control. In your case it looks like you hardly ever want to reject a call to returnSomeObjectBasedOnMetaObject() (or at least that's out of scope for the question as asked), and instead infer an intersection of the different T types mentioned in the different properties. So let's just use a separate type parameter for each of these appearances, and manually synthesize the intersection from them:

type MetaObject<T1, T2 = T1, T3 = T1> = {
  categoryOne: {
    [K in keyof T1]?: (this: Id<T1 & T2 & T3>) => string;
  };

  categoryTwo?: {
    [K in keyof T2]?: T2[K];
  };

  categoryThree?: {
    [K in keyof T3]?: number;
  };
};

type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;

declare const returnSomeObjectBasedOnMetaObject: <T1, T2, T3>(
  metaObject: MetaObject<T1, T2, T3>) => Id<T1 & T2 & T3>;

The Id<T> type is really just a way of formatting the type T more nicely (assuming it's just an object type or intersection of object types) by collapsing intersections into single object types; so Id<{a: 0} & {b: 1} & {c: 2}> is {a: 0, b: 1, c: 2}.

You can still write MetaObject<T>, which uses generic defaults to evaluate to MetaObject<T, T, T>, which you can verify is the same as your original version (assuming Id<T & T & T> is the same as T, which it usually is). But now we can write returnSomeObjectBasedOnMetaObject() to be generic in T1, T2, and T3, and infer these type parametes from metaObject of type MetaObject<T1, T2, T3>, and return Id<T1 & T2 & T3>.


Let's see if it works:

const someObject = returnSomeObjectBasedOnMetaObject({
  categoryOne: {
    a: () => 'hello',
    b: () => 'world',
  },

  categoryTwo: {
    b: 'a value',
    c: 'another value',
    d: 4346364
  },

  categoryThree: {
    x: 2,
    y: 75,
    z: 900
  }
});

/*
const someObject: {
    a: unknown;
    b: string;
    c: string;
    d: number;
    x: unknown;
    y: unknown;
    z: unknown;
}
*/

Looks good. Those unknown properties are the best the compiler can do, but b, c, and d are strongly typed because of categoryTwo.

Since this meets your needs I won't go any further, but it's definitely possible there are edge cases where this still doesn't behave as you'd like. Often these cases can be accommodated, but sometimes they can't... eventually you might hit a situation where inference breaks down and you need to manually specify the type parameters. But I'll stop here!

Playground link to code

  • Related