Typescript Generic with implicit attribute type

Viewed 626

Context

I'm having trouble figuring out if I can use implied values for generic arguments in types, the same way that's possible in functions.

Assume I have the following interface defining the structure of some of my database tables:

interface Tables {
  'table-users': { userId: string, username: string },
  'table-posts': { postId: string, userId: string, title: string },
}

I can write an oversimplified function that allows me to write to those tables using:

const write = <T extends keyof Tables>(table: T, item: Tables[T]): boolean => {
  // ... do something
  
  return true;
}

This allows me to do


write('table-users', { userId: '123', username: 'John Doe' }); // ✅
write('table-posts', { postId: 'abc', userId: '123', title: 'First Post' }); // ✅

But not

write('table-users', { postId: 'abc' }); // ❌
write('table-posts', { userId: 'abc' }); // ❌

So far, so good.

The problem

However, now I'd like to be able to construct a type (similar to the function) that takes a property that needs to be a table name, and another property in that type needs to have the correct item format.

I figured something along these lines should work:

type Item<T extends keyof Tables> = {
  table: T,
  item: Tables[T],
}

and automatically allow:

const userItem: Item = {
  table: 'table-users',
  item: {
    userId: '1',
    username: 'John Doe',
  }
};

const postItem: Item = {
  table: 'table-posts',
  item: {
    postId: '123',
    userId: '1',
    username: 'John Doe',
  }
};

or just use Item as function parameter typehint, but it does not, because it always requires explicit definition of the table name:

const userItem: Item<'table-users'> = {
  table: 'table-users',
  item: {
    userId: '1',
    username: 'John Doe',
  }
};

const postItem: Item<'table-posts'> = {
  table: 'table-posts',
  item: {
    postId: '123',
    userId: '1',
    title: 'First Post',
  }
};

Any clue if what I'm looking for is possible in TypeScript or not? I figure it should be, because it could be and I'm probably just doing something stupid, but I can't seem to fix it.

Edit

In contrast, this is valid:

type Item = {
  [T in keyof Tables]?: {
    item: Tables[T]
  }
}

but I'm hoping to do the same, but use T as a value, not a key.

2 Answers

You can make use of conditional types like so:

type ItemGeneric<T> = T extends keyof Tables ? {
    table: T;
    item: Tables[T];
} : never;

type Item = ItemGeneric<keyof Tables>;

This will implicitly set the type of item based on the string value you set for table. Here's a small demo of this in action.

Full credit to nmain on github for telling me about this.

After a lot of exploration and testing, I think I came up with something that might help you.

If I'm understanding the problem right, you want your Item type to have the property table be limited to values that match the keys of your previously defined Tables Interface.

To do this, you can simply write your Item type as follows:

type Item = {
    table: keyof Tables,
    item: Tables[keyof Tables]
}

Using this, your Item object will have it's table property limited to the keys of your Tables interface. Similarly, your item property will be limited to the values of your Tables interface.

This gets around needing to explicitly call out the table name via generic <> syntax.

However, This will NOT force your table value to match the item values.

// This is allowed, but not expected as table-users doesn't allow for postId
const postItem: Item = {
    table: 'table-users',
    item: {
        postId: '123',
        userId: '1',
        username: 'John Doe',
    }
};

If you want your table value to force the allowed value assigned to Item.item, you may have to pass the value in explicitly.

Alternatively, you can change the upstream logic so that an Item doesn't have to reference it's own table. This would simplify the typing as well.

type Item<T extends keyof Tables> = Tables[T]

I think the issue at the heart of this question is that Item.item would have to reference the value of Item.table and there is not a way to do that without explicitly providing it via generics. We don't have a way to self-reference the value the user assigns to Item.table in order to provide typing for Item.item corresponding to that table value.

Related