Ranjeet Eppakayala
Ranjeet Eppakayala

Reputation: 3028

How to declare type guard dynamically in typescript?

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

Answers (2)

Aluan Haddad
Aluan Haddad

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' };

Playground Link

Notes:

  1. Each withStrategy call returns a new object with a type that is further refined.

  2. The constraint doesn't need to involve the Strategy type, it could be an arbitrary string.

  3. 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' };
    

    Playground Link

  4. 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

Alex Wayne
Alex Wayne

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

Related Questions