Search code examples
typescriptassert

Guard against access to Optional<T>'s .value property when .hasValue is false


Below is a TypeScript Optional<T> class modeled loosely after Java's Optional class. How can I modify this class or create a class with similar semantics where TypeScript will enforce, or at least warn about the need for, checking .hasValue before accessing .value()?

(Using null or undefined as a sentinel value for "missing" is not desired, as that's precisely what this class is for--to avoid the use of sentinel values, so that "the result is null/undefined" can be distinguished from "there is no result.")

export default abstract class Optional<T> {
  abstract get hasValue(): boolean
  abstract get value(): T
  abstract resolve<TDefault>(defaultProvider: () => TDefault): T | TDefault

  static empty<TType>(): Optional<TType> { return emptyOptional as Optional<TType> }
  static of<TType>(value: TType): Optional<TType> { return new FilledOptional(value)}
}

class EmptyOptional<T> implements Optional<T> {
  get hasValue(): boolean { return false }
  get value(): T { throw new Error('Cannot access the value of an empty Optional<T>') }
  resolve<TDefault>(defaultProvider: () => TDefault): T | TDefault {
    return defaultProvider()
  }
}
const emptyOptional: unknown = new EmptyOptional<unknown>()

class FilledOptional<T> implements Optional<T> {
  constructor (readonly value: T) {}
  get hasValue(): boolean { return true }
  resolve<TDefault>(_: () => TDefault): T | TDefault {
    return this.value
  }
}

Consider this example code using the above Optional class:

// Get a possibly filled, possibly empty Optional<Item> from assumed function `findNext`
const candidateItem: Optional<Item> = findNext()
   
// This will throw if the Optional has no value, so this shouldn't be allowed
console.log(candidateItem.value)

if (candidateItem.hasValue) {
  // But the following line is guaranteed safe because `hasValue` was checked
  console.log(candidateItem.value)
}

How can I get TypeScript to complain about raw access to candidateItem.value that is not guarded by a check on .hasValue? I'm open to completely revamping my Optional<T> class implementation. There's no particular need to use exceptions the way Java does it. If the type system can enforce that .value is not accessed or used improperly when there is no value in the Optional, then we don't need to throw.

Function assertion return types came to mind, such as asserts paramName is TypeName which tells TypeScript that the function will throw if the paramName isn't type TypeName, where TypeName is a type compatible with the type of paramName, and thus code guarded by this function will result in type narrowing from the parameter type to the (possibly narrower) TypeName. But I'm not seeing how this could be used, because the type returned by value is simply given as T, so type narrowing doesn't help.


Solution

  • TypeScript enforces this kind of thing only through types, mostly through type discrimination and type narrowing. It has no mechanism for reasoning about interrelations between the properties and methods of a class at the class level, and can't do reasoning about the state of class instances other than through the type system. So, instead of making the EmptyOptional have a .value property that throws when accessed, make it have no .value property at all.

    The best way to do that is to change the Optional abstract class (which was functioning as an interface) to be a type union of two distinct types, only one with a .value property, so that TypeScript knows that the .value property can't be accessed until the type has been narrowed to something that is known to have that property. Simplifying the code from TypeScript Maybe Type and Module could yield something like this:

    // src/types/optional.d.ts
    type Optional<T> = { readonly hasValue: true, value: T } | { readonly hasValue: false }
    
    // src/optional.ts
    const emptyOptional: unknown = Object.freeze({ hasValue: false })
    
    export default Object.freeze({
      of<T>(value: T): Optional<T> {
        return {
          get hasValue() { return true },
          get value() { return value }
        }
      },
      empty<T>(): Optional<T> { return emptyOptional as Optional<T> }
    })
    

    Some notes:

    1. There's no need to create an enum or custom marker types or add any more complexity. The type discriminant can simply be a boolean as shown.

    2. By putting the type Optional declaration in its own .d.ts file without an export statement, it becomes an ambient type declaration, allowing code that merely uses Optionals without creating instances of them to not need to import anything.

    3. By exporting an object with the of and empty methods, in practice we get the same client code as in the question's implementation. The name of the import is left up to the consumer. If desired, Optional is fine, because TypeScript is okay with reusing the type name (as in import Optional from './optional') because it can tell when that symbol is used as a type or as a value.

    Type narrowing can occur in several ways, but here's one example:

    import Optional from './optional'
    
    const emptyNumber = Optional<number>.empty()
    const filledNumber = Optional.of(42)
    
    // Now, accessing .value before type narrowing is an error in both cases:
    // Property 'value' does not exist on type Optional<number>
    console.log(emptyNumber.value)
    console.log(filledNumber.value)
    
    if (emptyNumber.hasValue) {
      // This code block will not be hit
    }
    if (filledNumber.hasValue) {
      // It is safe to access `.value` and it has the appropriate type
      console.log(filledNumber.value)
    }
    if (!emptyNumber.hasValue) {
      // Design-time error because when `hasValue` is false, there is no `.value` property
      console.log(emptyNumber.value)
    }
    

    To get the resolve() method of the original question might look like this:

    // src/types/optional.d.ts
    type Optional<T> = {
      resolve<TDefault>(defaultProvider: () => TDefault ): T | TDefault
    } & ({
      readonly hasValue: true
      readonly value: T
    } | {
      readonly hasValue: false
    })
    
    // src/optional.ts
    const emptyOptional: unknown = Object.freeze({
      hasValue: false,
      resolve<TDefault>(defaultProvider: () => TDefault): TDefault {
        return defaultProvider()
      }
    })
    
    class FilledOptional<T> {
      constructor(readonly value: T) {}
      get hasValue() { return true }
      resolve<TDefault>(_: () => TDefault): T | TDefault {
        return this.value
      }
    }
    
    export default Object.freeze({
      of<T>(value: T): Optional<T> {
        return new FilledOptional(value)
      },
      empty<T>(): Optional<T> { return emptyOptional as Optional<T> },
    })