Search code examples
typescriptclassinheritanceoption-type

Implementing Maybe/Option type in TypeScript using class inheritance


Most of the Typescript implementations of the Maybe/Option type that I have seen either use an interface with a tag to distinguish between the Some/None variants. I have seen a few that don't and actually have separate classes for Some and None that both implement a common Option interface. As an experiment, I wanted to try an implementation that used inheritance. So here is a simplified version of what I came up with:

abstract class Maybe<T> {
    constructor(private value: T) {}

    isSome(): this is Some<T> {
        return this instanceof Some;
    }
  
    isNone(): this is None {
        return !this.isSome();
    }

    and<U>(other: Maybe<U>): Maybe<U> {
        return this.isNone() ? new None() : other;
    }

    unwrap(): T {
        if (this.isNone()) throw new Error('called unwrap() on None');
        return this.value;
    }

    unwrapOr(defaultValue: T): T {
        return this.isNone() ? defaultValue : this.value;
    }
}

class Some<T> extends Maybe<T> {
    constructor(value: T) { super(value); }
}

class None extends Maybe<any> {
    constructor() { super(null); }
}

But I'm running into some problems with the type being passed in to extends Maybe<...> on the None class. I have tried using never, null and any and they all lead to (different) compiler errors that force me to do some weird casts inside of Maybe's methods.

You can find a playground of the above code here.

Here is my thinking. The generic Type T on Maybe represents the type of the contained value and can be any type. But when Maybe is a None, it really should be a never, since, conceptually, it doesn't have a contained value. In keeping with that, my first thought was to make None extend Maybe<never> but then the call to super(null) triggers a compiler error as null is not assignable to type never.

The most usable version I came up with is the one where None extends Maybe<any> but that requires doing a bunch of casting of (<Some<T>this).value inside of Maybes methods (not shown in above code) and I don't like that. Ideally, I'd want to find an implementation that doesn't require the use of any and casts all over the place. Any help moving towards that will be appreciated.


Solution

  • The minimal change to your code necessary to get it working would probably be this:

    class None extends Maybe<never> {
        constructor() { super(null!); }
    }
    

    That is, we are saying that None is a Maybe<never>, where the never type is the bottom type with no values. This is reasonable from a type system standpoint: the "right" type would be something like forall T. Maybe<T>, but since TypeScript doesn't have "generic values" like microsoft/TypeScript#17574 there's no way to express such a type directly. Since it looks like Maybe<T> should be covariant in T, then you could rephrase forall T. Maybe<T> as Maybe<forall T. T>, which would be equivalent to Maybe<never> as the intersection of all types in TypeScript is never.

    But in order to construct a Maybe<never> with your code you need to supply a value of type never to the super constructor. This, of course, can't happen. You're passing null, which is not a never. We can use the non-null assertion operator (postfix !) to downcast null to never (the type of null! is NonNullable<null> which is never). This is technically a lie, but it's a fairly harmless one, especially if you never try to observe the value later.


    If you want to be type safe everywhere then I think you'd need to refactor your base class so that it doesn't pretend to do things it can't do. A Maybe<T> might not have a value so value probably shouldn't be required in the base class. It could be optional:

    abstract class Maybe<T> {
        constructor(public value?: T) { } // <-- optional
    
        unwrap(): T {
            if (this.isSome()) return this.value; // reverse check
            throw new Error('called unwrap() on None');
        }
    
        unwrapOr(defaultValue: T): T {
            return this.isSome() ? this.value : defaultValue; // reverse check
        }
    }
    
    class Some<T> extends Maybe<T> {
        declare value: T // <-- narrow from optional to required
        constructor(value: T) { super(value); }
    }
    
    class None extends Maybe<never> {
        constructor() { super(); } // <-- don't need an argument now
    }
    

    And then you can declare the property in the subclass to be required (and I have to make it public since private doesn't let subclasses look at things and even protected gets iffy with those type guard functions you have). Note that I switched your checks from isNone() to isSome()... the Maybe<T> class is not known to be a union of Some<T> | None, so you can't use a false result of isNone() to conclude that this.value is present. Instead you should use a true result of isSome().


    Finally, you could just move all the Some/None specific functionality out of Maybe, and then stop worrying about trying to force the base class to behave like both subclasses. Inheritance-based polymorphism tends to prefer subclasses to override methods instead of having a superclass method that checks for instanceof. This is similar to having Maybe just be an interface, with the exception of any truly polymorphic methods that don't need to check for the current class:

    abstract class Maybe<T> {
        abstract isSome(): this is Some<T>;
        abstract isNone(): this is None;
        abstract and<U>(other: Maybe<U>): Maybe<U>;
        abstract unwrapOr(defaultValue: T): T;
    }
    
    class Some<T> extends Maybe<T> {
        private value: T
        constructor(value: T) { super(); this.value = value; }
        isSome(): this is Some<T> { return true; }
        isNone(): this is None { return false; }
        and<U>(other: Maybe<U>) {
            return other;
        }
        unwrap() { return this.value }
        unwrapOr(defaultValue: T) { return this.value }
    }
    
    class None extends Maybe<never> {
        isSome<T>(): this is Some<T> { return false }
        isNone(): this is None { return true }
        and<U>(other: Maybe<U>) {
            return this;
        }
        unwrapOr<T>(defaultValue: T) { return defaultValue }
    }
    

    Here, there's nothing in Maybe except for abstract methods. If you can come up with something that doesn't need to check if this.isSome() or this.isNone() then it can go in the base class. The only noteworthy thing here is that some of the subclassing for Maybe<never> involves turning non-generic methods (isSome and unwrapOr) into generic methods.


    Playground link to code