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.
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:
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.
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.
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> },
})