Reputation: 9583
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
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:
[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.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...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'
, etcUpvotes: 1