Search code examples
typescripttypescript-generics

Typescript Generics - Keep inference when typing object


I have an issue when using a generic type with default arguments where I lose the benefit of inference if the variable is declared with the generic type.

Given the following types:

type Attributes = Record<string, any>;

type Model<TAttributes extends Attributes = Attributes> = {
  attributes: TAttributes;
};

function create<TModels extends Record<string, Model>>(
  schemas: TModels
): TModels {
  return schemas;
}

If I type objects using Model, and then pass that object into my generic function, I lose the inference benefit I get if I were to not type the object. It works as expected if the generics argument is passed into the Model type. As seen here:

const TodoWithGeneric: Model<{ name: string; finished: string }> = {
  attributes: {
    name: "string",
    finished: "boolean"
  }
};

const TodoWithoutGeneric: Model = {
  attributes: {
    name: "string",
    finished: "boolean"
  }
};

const withInference = create({
  Todo: { attributes: { name: "string", finished: "boolean" } }
});
const withGenericsPassed = create({ Todo: TodoWithGeneric });
const withoutAttributesPassedToGeneric = create({
  Todo: TodoWithoutGeneric
});

Is there a TypeScript workaround to still have the benefit of typing the declaration of the object, without having to pass in generic arguments, yet keep the benefit of no-typing/inference once it's passed into a function?

Ideally, we would have the typescript support on the declaration for TodoWithoutGeneric, but by the time it is passed into withoutAttributesPassedToGeneric, we strip off the Model type and allow inference to take over.

The combined snippets in this question are setup in this sandbox: Code Sandbox here

The withInference.Todo.attributes. and withGenericsPassed.Todo.attributes. have the attribute keys available, whereas the withoutAttributesPassedToGeneric.Todo.attributes (typed with generic) does not.

Thank you!


Solution

  • Once you annotate a variable with a (non-union) type like Model,

    const TodoWithoutGeneric: Model = ⋯;
    

    then that's all the compiler knows about the type of the variable. It has been widened all the way to Model. Any specific information about the initializer has been discarded. So if you need to retain more specific information, you can't annotate the type like this.

    Presumably you just want to be sure that TodoWithoutGeneric satisfies the Model type without actually widening it to that type. If so, you can use the satisfies operator:

    const TodoWithoutGeneric = {
        attributes: {
            name: "string",
            finished: "boolean"
        }
    } satisfies Model;
    

    You'll still get an error if the initializer doesn't conform to Model, but now the type of TodoWithoutGeneric is the more specific type

    /* const TodoWithoutGeneric: {
        attributes: {
            name: string;
            finished: string;
        };
    } */
    

    And the rest of your code behaves as desired.

    Playground link to code