javascripttypescriptastrojs

Type created with keyof cannot be used to index type it was created from


I'm trying to write a utility function in my TypeScript project to sort/filter objects in a fairly predictable data model (I'm using Astro for this site). The objects keep all the data useful for sorting/filtering in the data property.

I cannot figure out how to properly type this function, which takes a collection of objects and the name of a property as arguments: foo( collection, property ) { ... }.

Here's a super simplified version of what I've been trying to do. It doesn't take a collection as the first arg, but the gist is the same: I want to pass in the name of a property in data and then access the value of that property.

interface User {
  data: {
    id: number;
    name: string;
  }
}
interface House {
  data: {
    number: number;
    street: string;
  }
}
type Obj = User | House;

function getProperty<
  T extends Obj,
  K extends keyof T['data']
>(obj: T, key: K): T['data'][K] {
  return obj.data[key];
  // Type 'K' cannot be used to index type '{ id: number; name: string; } | { number: number; street: string; }'.ts(2536) 
}
const user: User = { data: { id: 1, name: "Alice" }};
const house: House = { data: { number: 2, street: "First" }};

const userName = getProperty(user, "name");
const houseNumber = getProperty(house, "number");

TypeScript understands what's going on with the function, as the last two lines won't let me pass in a property name that isn't in the corresponding object's data. But for the life of me, I don't know how to get K to behave.


Solution

  • You've run into the issue reported at microsoft/TypeScript#33181. If you have an object whose type is a generic type, like o of type O extends {x: string}, and you index into it with a specific (non-generic) key like "x", then TypeScript tends to widen the generic to its constraint before doing the indexing. So the type of o.x is seen as just string and not the generic indexed access type O["x"]. That widening isn't necessarily wrong, but it doesn't always meet the needs of developers.

    Until and unless microsoft/TypeScript#33181 is addressed, you'll need to work around it. One way is to explicitly annotate a variable with the intended type and assign the result of indexing to it, like const ox: O["x"] = o.x. For your code that looks like

    function getProperty<
        T extends Obj,
        K extends keyof T['data']
    >(obj: T, key: K): T['data'][K] {
        const data: T['data'] = obj.data;
        return data[key];
    }
    

    Another approach is to change the types so the type of the object being indexed into isn't itself a generic type parameter, but rather a specific object type whose property is generic, like this:

    function getProperty<
        D extends Obj['data'],
        K extends keyof D
    >(obj: { data: D } & Obj, key: K): D[K] {
        return obj.data[key];
    }
    

    Here D takes the place of T["data"]. Note that I've constrained obj to not just {data: D} but {data: D} & Obj because presumably in practice Obj might have other properties other than data and we want to make sure that obj conforms to it. (Using an intersection in place of a constraint is a trick that can be used to work around various issues in TypeScript, such as microsoft/TypeScript#7234.)

    Playground link to code