Ivan V.
Ivan V.

Reputation: 8081

Generic data by object key

This is the code I have:

type Bucket = {
  [key:string]:Map<string,{data:any}>
}

Now what I'm trying to do is to create a type that would allow me to have custom data by key. As you can see in the below code, I would like to strongly type the data object for every key. I've tried by having the generic object on the Bucket type but that locks all the keys to the same data.

Is there a way to have different strongly typed data for every key?

const bucket:Bucket = {
  a:new Map(),
  b:new Map<string,{data:boolean}>()
}

bucket.a.set('a',{data:1})
bucket.b.set('b',{data:'b'}) // this should error

const getA = bucket.a.get('a') // data is any - should be inferred to number?
const getB = bucket.b.get('b') // data is any - should be boolean

TS Playground

Upvotes: 2

Views: 349

Answers (1)

0Valt
0Valt

Reputation: 10345

When you define a Bucket type with an indexed signature ({ [x: <key type>] : <value type> }), you tell the compiler that there is no direct correlation between specific elements and their types. Also, any is not a "placeholder" type, it opts out of type checking. The compiler thus shows you what you told it, that:

  1. all keys of bucket are of type string
  2. bucket has values of type Map<string, { data: any }.

You then assign an object to the bucket variable. At this point, the value is checked for compatibility with the provided type (Bucket) and since you disabled type checking for data member, anything goes, thus no error.

Next, when you get value of a member of bucket, the type is inferred as { data: any; } | undefined because there is no guarantee that "a" or "b" will be present in [x: string], hence the value can be undefined. We already talked about why data is not narrowed.

If you remove type annotation from the bucket, you will notice the inference improves drastically because the type is now inferred as well: { a: Map<any, any>; b: Map<string, { data: boolean; }>. At the same time, you lose type checking for what members of bucket can contain.

So, what can you do about it? You need to explicitly tell the compiler what is the correlation between certain keys and values. Making your Bucket generic can help us achieve just that:

type Bucket<T> = {
  [ P in keyof T ]: Map<string, { data: T[P] }>
}

Now you have correctly inferred data types, assurance about the shape of bucket, and autocompletion at a cost of being more verbose which is a small price to pay for these benefits (besides, you can make the type parameter an interface or a type):

type Bucket<T> = {
  [ P in keyof T ]: Map<string, { data: T[P] }>
}

const bucket: Bucket<{ a: number, b: boolean, c?: string }> = {
  a:new Map(),
  b:new Map(),
};

bucket.a.set('a', { data: 1 })
bucket.b.set('b', { data: 'b' }) // Type 'string' is not assignable to type 'boolean'

const getA = bucket.a.get('a') // data is number
const getB = bucket.b.get('b') // data is boolean

Playground


An identity function like the one mentioned by Aleksey L. does the same thing - removes explicit type annotation from bucket while preserving type-checking (extends Bucket constraint) and returning a literal type (<T>(a:T) => T form). It has a cost of emitting a function in compiled code, but it allows you to infer the bucket type.

Upvotes: 2

Related Questions