Reputation: 3028
I want to set type guard dynamically depends on array elements
Working on strategy pattern in typescript. here is the code
class StrategyManager {
strategies: Strategy[]
constructor() {
this.strategies = []
}
addStrategy(strategy: Strategy) {
this.strategies.push(strategy)
}
getStrategy(name: <dynamic>) { // this is where I need dynamic type guard
return this.strategies.find((strategy) => strategy.name === name)
}
}
Suppose strategies are added like so:
const sm = new StrategyManager()
sm.addStrategy({name:'foo'})
sm.addStrategy({name:'bar'})
Then;
while getting strategy
using sm.getStrategy
. I need name
parameter of type 'foo' | 'bar'
Thus intellisense will throw error like so:
sm.getStrategy('baz') // Intellisense error: `Argument of type '"baz"' is not assignable to parameter of type '"foo" | "bar"'`
Upvotes: 1
Views: 638
Reputation: 31823
Inspired by @ChisBode's comment, you can achieve this if you alter your implementation as follows.
Instead of using a mutable object that builds up an array value via successive mutations, you can design your strategy manager as an immutable object that builds up an array type via successive transformations.
Here's a working prototype:
class StrategyManager<N extends Strategy['name'] = never> {
strategies: (Strategy & { name: N })[] = [];
withStrategy<S extends Strategy>(strategy: S): StrategyManager<N | S['name']> {
const result = new StrategyManager<N | S['name']>();
result.strategies = [...this.strategies, strategy];
return result;
}
getStrategy<T extends N>(name: T) {
return this.strategies.find(
(strategy): strategy is typeof strategy & { name: T } => strategy.name === name
);
}
}
new StrategyManager()
.withStrategy({ name: 'bar' })
.getStrategy('foo')?.name // error as desired
new StrategyManager()
.withStrategy({ name: 'bar' })
.getStrategy('bar')?.name // ok; typeof name is 'bar' | undefined
new StrategyManager()
.withStrategy({ name: 'bar' })
.withStrategy({ name: 'foo' })
.getStrategy('foo')?.name // ok; typeof name is 'foo' | undefined
type Strategy = { name: 'foo' | 'bar' };
Notes:
Each withStrategy
call returns a new object with a type that is further refined.
The constraint doesn't need to involve the Strategy
type, it could be an arbitrary string
.
Since we are following an immutable design pattern, we should really ensure that the strategies array underlying the manager cannot be modified via other means. To accomplish this, we can transition from a class to a factory, obtaining hard privacy via closures and reducing the amount of code we have to write as a bonus:
function strategyManager<N extends Strategy['name'] = never>(
strategies: (Strategy & { name: N })[] = []
) {
return {
withStrategy<S extends Strategy>(strategy: S) {
return strategyManager<N | S['name']>([...strategies, strategy]);
},
getStrategy<T extends N>(name: T) {
return strategies.find(
(strategy): strategy is typeof strategy & { name: T } => strategy.name === name
);
}
};
}
strategyManager()
.withStrategy({ name: 'bar' })
.getStrategy('foo')?.name // error as desired
strategyManager()
.withStrategy({ name: 'bar' })
.getStrategy('bar')?.name // ok; typeof name is 'bar' | undefined
strategyManager()
.withStrategy({ name: 'bar' })
.withStrategy({ name: 'foo' })
.getStrategy('foo')?.name // ok; typeof name is 'foo' | undefined
type Strategy = { name: 'foo' | 'bar' };
You could also achieve encapsulation via the Stage 3 ECMAScript private fields proposal but closures are supported in more environments and are simple and battle tested.
Upvotes: 1
Reputation: 187034
This can't be done as described.
Typescript can't keep track of all possible things you've added, because it doesn't really run your code. If any string value can be used at runtime, then typescript cannot possibly help you constrain that type.
What if you had?
sm.addStrategy(await getAsyncStrategyFromServer())
The type system can't know what what name that will have because it can't be known at compile time. The compiler can't help you with something the compile doesn't know.
You have to think of what is a compile time type error, and what is a runtime error.
In this case, at compile time, string
is the right type anywhere there is a strategy name. This is because, as you say, name can be any string.
But it's a run time error if you get a strategy name that has not been added, because strategies are added dynamically at run time. Which means you handle that error with logic, and not with the type system.
getStrategy(name: string) {
const strategy = this.strategies.find((strategy) => strategy.name === name)
if (strategy) {
return strategy
} else {
// or something.
throw new Error(`Strategy with name: ${name} not found`)
}
}
Upvotes: 1