A TypeScript port of Rust's Option<T> type, providing a type-safe way to handle nullable values and avoid null pointer exceptions.
- Installation
- Overview
- Quick Start
- Common Patterns
- TypeScript Type Narrowing
- Comparison with Other Approaches
- Common Pitfalls
- Design Philosophy
- Contributing
- License
- API Reference
Install the package using your favorite package manager:
npm install @rsnk/optionor
yarn add @rsnk/optionThe Option type represents an optional value: every Option is either Some and contains a value, or None, and does not. This pattern eliminates entire classes of bugs related to null and undefined handling.
// Instead of this:
const user = getUser(); // User | undefined
if (user) {
console.log(user.name);
}
// Write this:
const user = O.fromNullable(getUser()); // Option<User>
user.inspect(u => console.log(u.name));npm install @rsnk/optionimport O from "@rsnk/option";// Create an Option with a value
const num = O.from(42); // Option<number>
const str = O.from("hello"); // Option<string>
// Create an empty Option of given type
const empty = O.empty<string>(); // Option<string>
// From nullable values
const maybeUser = O.fromNullable(user); // Option<User>
// For narrowed types (when you need Some or None specifically)
const some = O.some(42); // Some<number>
const none = O.none; // None// Check if value exists
if (option.isSome()) {
console.log(option.unwrap());
}
// Transform values
option
.map(x => x * 2)
.filter(x => x > 10)
.unwrapOr(0);
// Pattern matching
option.match({
Some: value => console.log(value),
None: () => console.log("No value")
});Use Option when:
- Dealing with values that may be absent
- You want type-safe null handling
- Building composable transformations
Don't use Option when:
- Simple optional chaining is sufficient
- Performance is absolutely critical
- The added abstraction isn't worth the complexity
Important: Options are objects and always truthy. Never rely on implicit boolean coercion.
❌ Don't use if (option) or option && value
O.fromNullable(user)
.map(u => u.profile)
.mapNullable(p => p.email)
.filter(email => email.includes('@'))
.map(email => email.toLowerCase())
.unwrapOr('no-email@example.com');// Before
function getUserEmail(userId: string): string | null {
const user = findUser(userId);
if (!user) return null;
if (!user.profile) return null;
return user.profile.email || null;
}
// After
function getUserEmail(userId: string): Option<string> {
return O.fromNullable(findUser(userId))
.mapNullable(user => user.profile)
.mapNullable(profile => profile.email);
}const discount = O.fromNullable(user)
.filter(user => user.isPremium)
.map(() => 0.20)
.unwrapOr(0);This pattern shows how to compose several operations to safely parse and validate raw input.
type User = {
id: number;
name: string;
};
function findUser(id: string): Option<User> {
return O.from(id)
.map(s => parseInt(s, 10)) // Attempt to parse the string to an integer
.filter(n => !isNaN(n) && n > 0) // Validate that it's a positive number
.mapNullable(id => {
// In a real app, you might fetch this from a database
const db: Record<number, User> = {
1: { id: 1, name: "Alice" },
2: { id: 2, name: "Bob" },
};
return db[id];
});
}
const user1 = findUser("1"); // Some({ id: 1, name: "Alice" })
const user2 = findUser("foo"); // None
const user3 = findUser("-1"); // None
const user4 = findUser("3"); // NoneImplementing toString() provides better debugging and logging experience.
Benefits:
- Easy logging: Can log options directly in console and debug tools
- String operations: Template literals and concatenation work naturally
- No implicit coercion: Explicit method calls prevent unexpected behaviors
console.log(option); // no implicit toString()
console.log("Result:" + option); // implicit toString()
console.log("Result:" + option.toString()); // implicit toString()
console.log("Result:" + String(option)); // implicit toString()
console.log(`Result: ${option}`); // implicit toString()
console.log(`Result: ${option.toString()}`); // implicit toString()
console.log(`Result: ${String(option)}`); // implicit toString()Note: This implementation intentionally does not implement valueOf() to avoid unpredictable implicit coercion behaviors. Always use explicit methods like isSome(), unwrapOr(), etc. for conditional logic.
The Option type integrates seamlessly with TypeScript's type system:
// Type guards work automatically
const opt: Option<number> = getSomeOption();
if (opt.isSome()) {
const value: number = opt.unwrap(); // Type-safe!
}
// Type predicates
const mixed: Option<string | number> = O.from(42);
if (mixed.isSomeAnd((x): x is number => typeof x === "number")) {
const num: number = mixed.unwrap(); // TypeScript knows it's a number
}
// Filter with type guards
const filtered: Option<number> = mixed.filter(
(x): x is number => typeof x === "number"
);// Traditional
let email: string | undefined;
if (user && user.profile && user.profile.email) {
email = user.profile.email.toLowerCase();
}
// With Option
const email = O.fromNullable(user)
.mapNullable(u => u.profile)
.mapNullable(p => p.email)
.map(e => e.toLowerCase());// Optional chaining
const email = user?.profile?.email?.toLowerCase(); // string | undefined
// With Option
const email = O.fromNullable(user)
.mapNullable(u => u.profile)
.mapNullable(p => p.email)
.map(e => e.toLowerCase()); // Option<string>Option provides more explicit error handling and better composability, while optional chaining is more concise for simple access patterns.
A common mistake is to check for the presence of a value by treating the Option as a boolean. Option is an object, so it will always be "truthy" in JavaScript, even when it's a None.
const opt = O.empty<string>();
// ❌ Incorrect: This block will always execute
if (opt) {
console.log("This will always be logged!");
}
// âś… Correct: Use `isSome()` or `isNone()`
if (opt.isSome()) {
// This block will not execute
console.log("Value is present:", opt.unwrap());
}This implementation prioritizes developer experience and productivity. Key design decisions:
Uses class instances instead of utility functions for better IDE autocomplete, method chaining, and type inference. No need to update import any time you need a new operation.
// Class-based (this implementation)
O.some(42)
.map(x => x * 2)
.filter(x => x > 50)
.unwrapOr(0);
// vs utility-based alternative
pipe(
some(42),
map(x => x * 2),
filter(x => x > 50),
unwrapOr(0)
);Some and None are implemented as separate classes that extend a common Option abstract class. This design has several tradeoffs compared to the more common discriminated union pattern.
Pros of Separate Classes (This Implementation):
- Performance: Each class has its own method implementations, avoiding
if (isSome(option))checks within each method. This results in more direct and slightly faster execution. - Cleaner Implementation: Method logic is cleaner as it doesn't require branching. For example,
maponNonealways returnsNonewithout executing the mapping function. - Singleton
None:Noneis an immutable singleton, which reduces memory allocations for empty options.
Cons of Separate Classes:
- Serialization: Class instances are not easily serializable.
- Less Common Pattern: Most developers familiar with functional TypeScript might expect a discriminated union.
A discriminated union is an alternative way to define Option, typically using a _tag property to distinguish between variants.
type Option<T> =
| { readonly _tag: 'Some'; readonly value: T }
| { readonly _tag: 'None' };Pros of Discriminated Unions:
- Idiomatic & Predictable: This is a standard pattern in the functional TypeScript ecosystem, making it familiar to many developers.
- Easy Serialization: As plain objects, they can be losslessly serialized with
JSON.stringifyand deserialized withJSON.parse.
Cons of Discriminated Unions:
- No Method Chaining: Operations must be performed with standalone utility functions, which can be less ergonomic than method chaining (e.g.,
pipe(option, map(fn))vs.option.map(fn)). - Runtime Checks: Every operation needs to perform a runtime check on the
_tagproperty, which can be slightly less performant than the class-based virtual dispatch.
By design, calling unwrap() on Option<T> returns unknown, forcing you to do type narrowing first. This prevents unsafe unwrapping and entire classes of runtime errors.
const option: Option<number> = O.from(42);
// ❌ This won't compile - unwrap() returns unknown on Option<T>
const value: number = option.unwrap();
// âś… Correct: Check first, then unwrap returns T
if (option.isSome()) {
// After type narrowing, option is Some<number>
const value: number = option.unwrap(); // Returns number, not unknown!
}
// âś… Alternative: Use safe unwrapping methods
const value = option.unwrapOr(0); // Always safe
const value = option.unwrapOrUndefined(); // Always safeThis design ensures you always handle the empty case explicitly, making your code more robust and preventing the "Cannot unwrap None" error at runtime.
Why this matters:
// Without type narrowing enforcement, this could crash:
function dangerousCode(opt: Option<User>): string {
const user = opt.unwrap(); // Would crash if None!
return user.name;
}
// With type narrowing enforcement, you must handle both cases:
function safeCode(opt: Option<User>): string {
if (opt.isSome()) {
const user = opt.unwrap(); // Safe - TypeScript knows it's Some
return user.name;
}
return "Unknown";
}
// Or use safe alternatives:
function safeCode2(opt: Option<User>): string {
return opt.mapOr("Unknown", user => user.name);
}Contributions are welcome! Please open an issue or submit a pull request.
To get started with development:
-
Clone the repository:
git clone https://github.com/oknesar/option.git cd option -
Install dependencies:
npm install
-
Run the tests:
npm run test -
Run the build:
npm run build
This project is licensed under the MIT License.
Creates an Option containing the given value. This is the recommended factory function for general use.
O.from(42); // Option<number>
O.from("hello"); // Option<string>
O.from({ id: 1 }); // Option<{id: number}>Returns an empty Option with a specific type annotation. This is the recommended way to create an empty Option.
const noString = O.empty<string>(); // Option<string>
noString.isNone(); // trueCreates a Some option containing the given value. Returns the narrowed Some<T> type. Use this when you need the specific Some type rather than the general Option type.
const num = O.some(42); // Some<number> (not Option<number>)
num.unwrap(); // 42A singleton None instance representing no value. Returns the narrowed None type. Use this when you need the specific None type rather than O.empty().
const empty = O.none; // None (not Option<never>)
empty.isNone(); // trueCreates an Option from a nullable value. Returns None if the value is null or undefined, Some otherwise.
O.fromNullable(null); // None
O.fromNullable(undefined); // None
O.fromNullable(42); // Some<number>
O.fromNullable(0); // Some<number> (0 is not null!)
O.fromNullable(""); // Some<string> (empty string is valid!)Returns true if the option contains a value, enabling TypeScript type narrowing.
const opt = O.fromNullable(value);
if (opt.isSome()) {
// TypeScript knows opt contains a value here
const val = opt.unwrap(); // Safe!
}Returns true if the option is None.
if (opt.isNone()) {
console.log("No value present");
}Returns true if the option contains a value and the predicate returns true.
const opt = O.from(42);
opt.isSomeAnd(x => x > 0); // true
opt.isSomeAnd(x => x < 0); // false
// Works with type guards
const mixed: Option<string | number> = O.from(42);
if (mixed.isSomeAnd((x): x is number => typeof x === "number")) {
// TypeScript knows the value is a number here
}Returns true if the option is empty or the predicate returns true.
O.from(42).isNoneOr(x => x > 0); // true
O.from(42).isNoneOr(x => x < 0); // false
O.empty().isNoneOr(() => false); // trueReturns true if the option contains the given value.
O.from(42).contains(42); // true
O.from(42).contains(10); // false
O.empty().contains(42); // falseReturns the contained value.
unknown on Option<T> - requires type narrowing first! After calling isSome(), TypeScript narrows the type to Some<T> and unwrap() returns T.
const opt = O.from(42);
// Type narrowing required
if (opt.isSome()) {
const value: number = opt.unwrap(); // Returns number
}
// Alternative: use safe methods
const value = opt.unwrapOr(0); // Always returns numberReturns the contained value. Unlike unwrap(), this always returns T (not unknown) but will throw with a custom error message if empty.
const opt = O.from(42);
const value: number = opt.expect("should have value"); // 42 - no narrowing needed!
O.empty().expect("missing value"); // Throws: "missing value"Returns the contained value or a provided default.
O.from(42).unwrapOr(0); // 42
O.empty().unwrapOr(0); // 0Returns the contained value or computes it from a callback.
O.from(42).unwrapOrElse(() => 0); // 42
O.empty().unwrapOrElse(() => expensive()); // Result of expensive()Returns the contained value or undefined.
O.from(42).unwrapOrUndefined(); // 42
O.empty().unwrapOrUndefined(); // undefinedTransforms the contained value by applying a function. Returns an empty Option if the original is empty.
O.from(42)
.map(x => x * 2) // Option<number> with 84
.map(x => String(x)); // Option<string> with "84"
O.empty().map(x => x * 2); // Option<never> (empty)Applies a function to the contained value and unwraps, or returns a default.
O.from(42).mapOr(0, x => x * 2); // 84
O.empty().mapOr(0, x => x * 2); // 0Applies a function to the contained value and unwraps, or computes a default.
O.from(42).mapOrElse(() => 0, x => x * 2); // 84
O.empty().mapOrElse(() => 0, x => x * 2); // 0Applies a function and returns the result or undefined.
O.from(42).mapOrUndefined(x => x * 2); // 84
O.empty().mapOrUndefined(x => x * 2); // undefinedMaps with a function that may return null or undefined, automatically wrapping the result.
O.from({ name: "John" })
.mapNullable(user => user.email); // Empty if email is null/undefined
O.from("42")
.mapNullable(s => {
const n = parseInt(s);
return isNaN(n) ? null : n;
});Returns the Option if the predicate returns true, otherwise returns None.
O.from(42)
.filter(x => x > 0); // Option with 42
O.from(42)
.filter(x => x < 0); // None
// With type guards
const mixed: Option<string | number> = O.from(42);
const onlyNumbers = mixed.filter((x): x is number => typeof x === "number");Chains option-returning functions (also known as flatMap).
const getUser = (id: number) => O.fromNullable(findUser(id));
const getEmail = (user: User) => O.fromNullable(user.email);
O.from(123)
.andThen(getUser)
.andThen(getEmail); // Option<string>Flattens a nested Option.
O.from(O.from(42)).flatten(); // Option with 42
O.from(O.none).flatten(); // None
O.none.flatten(); // NoneIt's important to note that Option itself is synchronous and does not provide asynchronous chaining mechanisms like Promise.then(). Instead, it offers helpers for unwrapping Option<Promise<T>> into Promise<Option<T>>, allowing developers to await the Promise independently.
Maps with an async function.
await O.from(42)
.mapAsync(async x => {
const result = await fetchData(x);
return result;
});Maps with an async function that may return null or undefined.
await O.from(userId)
.mapNullableAsync(async id => await fetchUser(id));await O.from(userId)
.andThenAsync(async id => {
const user = await fetchUser(id);
return O.fromNullable(user);
});Converts Option<Promise<T>> into Promise<Option<T>>.
const opt: Option<Promise<number>> = O.from(Promise.resolve(42));
const result: Promise<Option<number>> = opt.transposePromise();
await result; // Option with 42Returns None if this is empty, otherwise returns other.
O.from(42).and(O.from("hello")); // Option with "hello"
O.empty().and(O.from("hello")); // NoneReturns this option if it contains a value, otherwise returns other.
O.from(42).or(O.from(100)); // Option with 42
O.empty().or(O.from(100)); // Option with 100Returns this option if it contains a value, otherwise calls fn.
O.from(42).orElse(() => O.from(100)); // Option with 42
O.empty().orElse(() => O.from(100)); // Option with 100Returns an Option if exactly one of the options contains a value, otherwise returns empty.
O.from(42).xor(O.none); // Option with 42
O.empty().xor(O.from(42)); // Option with 42
O.from(42).xor(O.from(100)); // None (both have values)
O.empty().xor(O.none); // None (both are empty)Combines two options into an option of a tuple.
O.from(42).zip(O.from("hello")); // Option with [42, "hello"]
O.from(42).zip(O.none); // NoneCombines two options using a reducer function.
O.from(42).zipWith(
O.from(10),
(a, b) => a + b
); // Option with 52Splits an option of a tuple into a tuple of options.
const zipped = O.from([42, "hello"] as [number, string]);
const [num, str] = zipped.unzip(); // [Option with 42, Option with "hello"]
const empty = O.empty<[number, string]>();
const [a, b] = empty.unzip(); // [None, None]Pattern matches on the option, calling the appropriate handler.
const result = O.from(42).match({
Some: value => `Got: ${value}`,
None: () => "No value"
}); // "Got: 42"
const result2 = O.empty().match({
Some: value => `Got: ${value}`,
None: () => "No value"
}); // "No value"Converts the option to an array with 0 or 1 elements.
O.from(42).asArray(); // [42]
O.empty().asArray(); // []
// Useful for spreading into arrays
const values = [...O.from(1).asArray(), 2, 3]; // [1, 2, 3]Calls a function with the contained value for side effects, returns the original option.
O.from(42)
.inspect(x => console.log(`Value is: ${x}`))
.map(x => x * 2);Returns a string representation of the option for debugging and logging. Returns the stringified value if present, empty string if not.
Why this is useful:
- Debugging: Easy to log Options without explicit unwrapping
- Template literals: Natural string interpolation
- UI display: Safe rendering without null checks
- No coercion issues: Only affects string contexts
O.from(42).toString(); // "42"
O.from("hi").toString(); // "hi"
O.empty().toString(); // ""
// Logging and debugging
console.log(`Debug: ${idOption}`); // Works in templates
logger.info({user: userOpt.toString()}); // Explicit call
// String operations
const message = "Value: " + option.toString(); // Explicit
const message = "Value: " + option; // Implicit
const message = `Value: ${option}`; // ImplicitNote: This implementation intentionally does not implement valueOf() to prevent unpredictable implicit type coercion. Always use explicit methods (isSome(), unwrapOr(), etc.) for conditional logic and value extraction.