ptpaterson
ptpaterson

Reputation: 9583

Typescript type for two types with same keys and similar values

I have a class that take an object as a constructor argument, and I want to enforce a method on the class to accept a very similar object. The objects have arbitrary keys.

For example, constructing with this object:

{
  foo: { type: 'A' },
  bar: { type: 'B'}
}

I will want the method to only accept objects of a similar form, i.e. has the same keys and for each key the value type is compatible with the initial object. like:

{
  foo: SomeARelatedThing,
  bar: SomeBRelatedThing
}

I've got a workaround in place where I can at least enforce the same keys, and then do lots of type checking (good to do anyway!) to make sure that the values actually match up.

Here's a contrived example from my use case:

type TypeName = 'A' | 'B' | 'C' // ...

class Action<K extends TypeName> { 
  type: K
  constructor(type: K) { this.type = type } 
}
type AnyAction = Action<'A'> | Action<'B'> | Action<'C'> // ...

type AProp = { type: 'A' }
type BProp = { type: 'B' }
type CProp = { type: 'C' }
type AnyProp = AProp | BProp | CProp // ...

type PropMap<K extends string> = Record<K, AnyProp>
type ActionMap<K extends string> = Record<K, AnyAction>

class Thing<K extends string> {
  props: PropMap<K>
  constructor(props: PropMap<K>) { this.props = props }

  myMethod<>(actions: ActionMap<K>) { /* ... */ }
}

// type = Thing<'foo' | 'bar'>
const thing = new Thing({
  foo: { type: 'A' },
  bar: { type: 'B'}
})

// the keys are enforced, but how can the values of foo and bar be enforced, too
thing.myMethod({
  foo: new Action('A'),
  bar: new Action('B'),
}) 

I think I would want something more like a type equal to Thing<{foo: 'A', bar: 'B'}>, but I don't know how to conditionally compute that from a PropMap-like input to the Thing constructor, or even if I did, then how would I compute the correct ActionMap-like type.

MyMethod actually accepts a Partial<ActionMap<K>> but I don't think that that should matter for what I am asking.

Upvotes: 2

Views: 2933

Answers (1)

jered
jered

Reputation: 11571

I think I've got it. You need to use mapped types.

class Thing<K extends string, P extends PropMap<K>> {
  props: P
  constructor(props: P) { this.props = props }

  myMethod(actions: {[Property in keyof P]: Action<P[Property]["type"]>}) { return actions }
}

const thing = new Thing({
  foo: { type: 'A' },
  bar: { type: 'B'}
})

// the keys are enforced, as well as the corresponding Action types
thing.myMethod({
  foo: new Action('A'),
  bar: new Action('B'),
}) 

Note that you need to add the generic P to your Thing class, otherwise TypeScript has no way of inferring more detailed information when you instantiate Thing later. Basically, you need to set P to be the same generic type consistently within Thing otherwise there is no way to differentiate it from the type PropMap which it extends.

Then, the magic happens in actions: {[Property in keyof P]: Action<P[Property]["type"]>}. Let's break it down:

  1. [Property in keyof P]: mapped type index signature. This is what lets us get access to the specific keys in P, e.g. foo, bar etc.
  2. Action<...> will set the value corresponding to each key we're mapping in (1.) above to some value, which we want to be an Action, like Action<'A'> etc. BUT we want the action to be derived from the original value of P, so...
  3. P[Property]["type"] let's us access the value from the type key/value pair from the original type of P. Since Property varies (it's mapped from one type to the other) then for example it becomes foo: P["foo"]["type"] which is 'A', bar: P["bar"]["type"] which is 'B', etc

Playground

enter image description here

Upvotes: 1

Related Questions