How to automatically narrow the type in a `Record` typed object?

Viewed 47
enum ResourceType {
  A = 'a',
  B = 'b',
}

export type Resource = ResourceA | ResourceB

export type ResourceA = {
  type: ResourceType.A
  name: "A"
}

export type ResourceB = {
  type: ResourceType.B,
  name: "B"
}

const elements: Record<
  ResourceType,
  (resource: Resource) => void
> = {
  a: (resource: ResourceA) => (
    console.log(resource)
  ),
  b: (resource: ResourceB) => (
    console.log(resource)
  )
}

Typescript here says Type '(resource: ResourceA) => void' is not assignable to type '(resource: Resource) => void'.

That's expected.

I'm trying to find a way to automatically narrow the type based on the object key which should be matched with resource.type. I just can't find a way to do that.

2 Answers

Based on @bugs answer, we do not really need a mapping since we already have the association stored in each Resource type. We can map over the Resource union to create your type.

type Elements = {
  [R in Resource as `${R["type"]}`]: (resource: R) => void
}

const elements: Elements = {
  a: (resource: ResourceA) => (
    console.log(resource)
  ),
  b: (resource: ResourceB) => (
    console.log(resource)
  )
}

Playground

This is how I would go about it.

Right now, you don't have a type that represents the mapping between resource types and resources, so we'll need to define it.

type ResourceMapping = {
  [ResourceType.A]: ResourceA
  [ResourceType.B]: ResourceB
}

Then, you can use mapped types to specify the exact shape of your record.

type ResourceRecord = {
  [R in ResourceType]: (r: ResourceMapping[R]) => void
}

Putting everything together, this is how it looks like

enum ResourceType {
  A = 'a',
  B = 'b',
}

export type ResourceA = {
  type: ResourceType.A
  name: "A"
}

export type ResourceB = {
  type: ResourceType.B,
  name: "B"
}

export type Resource = ResourceA | ResourceB

type ResourceMapping = {
  [ResourceType.A]: ResourceA
  [ResourceType.B]: ResourceB
}

type ResourceRecord = {
  [R in ResourceType]: (r: ResourceMapping[R]) => void
}

const elements: ResourceRecord = {
  a: (resource: ResourceA) => (
    console.log(resource)
  ),
  b: (resource: ResourceB) => (
    console.log(resource)
  )
}

Playground link


This way, adding a new enum entry without doing anything else will result in a compilation error, forcing you to make sure that you have an appropriate mapping between the new entry and a new resource (you can try it out in the playground).

Similarly, specifying the wrong value for a given resource type will fail at compilation time.

const wrongElements: ResourceRecord = {
   // Type '(resource: ResourceB) => void' is not assignable to type '(r: ResourceA) => void'.
  a: (resource: ResourceB) => (
    console.log(resource)
  ),
  b: (resource: ResourceB) => (
    console.log(resource)
  )
}
Related