Search code examples
oopdesign-patternsinterfacecastingpolymorphism

How to avoid typecasting to subclass when using null object pattern


I have a Value interface with a method to show value as a string. Usually the value is an integer so IntegerValue implements Value. Sometimes value is unknown which I use null object pattern for so UnknownValue implements Value.

When the value is actually an integer, it's useful for the client to check whether the value is high enough (IntegerValue.isEnough). This affects how this value is displayed to the user later on. However, if the value is unknown, it doesn't make sense to check if it's high enough--the value is unknown. By the Interface Segregation Principle, UnknownValue should not have an isEnough method.

interface Value {
  toString(): string;
}

class IntegerValue implements Value {
  private value: number;
  constructor(v: number) { this.value = v }
  isEnough() { return this.value >= 30 }
  toString() { return '' + this.value }
}

class UnknownValue implements Value {
  toString() { return 'unknown' }
}

But the client accesses a Value and won't know whether it's an IntegerValue. So I'd have to check and then typecast it.

if(value.toString() !== 'unknown') {
  handleInteger(value as IntegerValue) // <-- check if isEnough inside
} else {
  handleUnknown(value)
}

I was wondering if there was a design pattern that could solve this with polymorphism, without typecasting.

I was considering the Visitor Pattern like so:

interface ValueVisitor {
  handleInteger(v: IntegerValue): void;
  handleUnknown(): void
}

class ViewValueVisitor implements ValueVisitor { ... }
class JsonSerializerValueVisitor implements ValueVisitor { ... }

interface Value {
  toString(): string;
  acceptVisitor(v: ValueVisitor): void;
}

class IntegerValue implements Value {
  ...
  acceptVisitor(v) { v.handleInteger(this) }
}

class UnknownValue implements Value { 
  ...
  acceptVisitor(v) { v.handleUnknown() }
}

But the Visitor Pattern violates the Open Closed Principle. I was wondering if there is a better solution.


Solution

  • This answer is very contrived for the problem scope of the default behavior of some value object and its Interface Segregation Principle violation. We can usually afford to sin a little and just type-cast or check the class in the client with value instanceof IntegerValue or value.getType() === 'integervalue'.

    But the inherent problem is not confined to only this problem scope. What happens when you have different classes implementing an interface that must be treated differently in the client. When there are more types involved, we may want to follow the SOLID principles to improve cohesion and encapsulation.

    Also not sure if this answer is supported by languages other than typescript, but... I think I got very close with my visitor pattern solution. Just needed one tweak so that the visitor pattern doesn't break the OCP. We can do that with the strategy pattern.

    enum HandledTypes {
      IntegerValue,
      UnknownValue,
      ...
    }
    
    interface ValueHandler {
      type: HandledType;
      handle(value: Value): void;
    }
    
    class ValueVisitor {
      handlers: Map<HandledTypes, ValueHandler>;
      constructor(handlers: ValueHandler[]) { ... }
    
      handle(key: HandledTypes, v: Value) {
        const h = this.handlers.get(key)
        h.handle(v);
      }
    }
    
    // a handler would expect a more specific type
    class ViewIntegerValueHandler implements ValueHandler {
      readonly type = HandledTypes.IntegerValue;
      handle(value: IntegerValue) { ... }
    }
    
    interface Value {
      toString(): string;
      acceptVisitor(v: ValueVisitor): void;
    }
    
    class IntegerValue implements Value {
      ...
      acceptVisitor(v) { v.handle(HandledTypes.IntegerValue, this) }
    }
    
    class UnknownValue implements Value { 
      ...
      acceptVisitor(v) { v.handle(HandledTypes.UnknownValue, this) }
    }
    

    Now we can compose a ValueVisitor with all the types it needs to handle within the client.

    function doSomething(value: Value) {
    
      const viewValueVisitor = new ValueVisitor([
        new ViewIntegerValueHandler(),
        new ViewUnknownValueHandler(),
      ]);
    
      value.acceptVisitor(viewValueVisitor);
    }
    

    One problem with this is that I don't see how TypeScript can warn you about providing the incorrect HandledTypes key to ValueVisitor.handle which may lead to a problem at runtime that may or may not throw an error.