[[TOC]]
ts-opt
![docs](https://img.shields.io/badge/docs-%E2%96%BD-blue)
Typed Option/Maybe for TypeScript and JavaScript (based on Scala, Haskell and Sanctuary), created to simplify code involving optional values.
Features
- ⛓️ TypeScript Support
- ⚙️ Strict types - it's a TypeScript-first library, no wild
any
s - but it is fully usable from JavaScript as well
- 🔩 Pragmatic - doesn't force functional programming paradigm
- 📏 100% Test Coverage
- 🗜️ Lightweight - no dependencies, bellow 10KiB gzip+minified
Installation
npm i -S ts-opt
Examples
In all examples opt
import is presumed to be present:
import { opt } from 'ts-opt';
Basic use
const f = (name: string | undefined) => {
if (!name || name === '') { throw new Error('Missing name.'); }
return name[0];
};
const g = (name: string | undefined) => opt(name).orCrash('Missing name.')[0];
f('Riker');
g('Riker');
f(undefined);
g(undefined);
onBoth
const fireMissiles = () => { console.log('FIRING!'); };
const printSuccess = (x: string) => { console.log(x); };
const handleMoveVanilla = (usersMove?: string): void => { if (usersMove) printSuccess(usersMove); else fireMissiles(); };
const handleMove = (usersMove?: string): void => opt(usersMove).onBoth(printSuccess, fireMissiles).end;
handleMoveVanilla();
handleMove();
handleMoveVanilla('Build a pylon.');
handleMove('Build a pylon.');
More advanced
interface Person {
name: string;
surname: string | null;
}
type Db = { [_: string]: Person };
const db: Db = {
'0': {name: 'John', surname: null},
'1': {name: 'Worf', surname: 'Mercer'}
};
const f = (id: number | undefined): string | null => {
if (id === undefined) { return null; }
const item = db[id];
if (!item) { return null; }
const surname = item.surname ? item.surname.toUpperCase() : '<missing>';
return item.name + ' ' + surname;
};
const g = (id: number | undefined): string | null => opt(id)
.chainToOpt(x => db[x])
.map(item => item.name + ' ' + opt(item.surname).map(x => x.toUpperCase()).orElse('<missing>'))
.orNull();
f(0);
g(0);
f(1);
g(1);
f(2);
g(2);
Looking inside wrapped values
You can "zoom" inside an (optional) structure of a value inside an opt.
prop
allows you to look inside fields (which can be optional).
You can use at
to focus on an index (possibly non-existing or holding an empty value) when value inside opt is an array.
interface House {
occupantIds?: number[];
}
const h = {
occupantIds: [7],
} as House | undefined;
opt(h)
.prop('occupantIds')
.at(0)
.orElse(-1)
opt(h)
.prop('occupantIds')
.at(99)
.orElse(-1)
const h2 = {} as House | undefined;
opt(h2)
.prop('occupantIds')
.at(0)
.orElse(-1)
Documentation
All methods are documented, if you don't see a description please make sure you are reading the base class page - Opt
.
- Main Opt class
- Module (constructors and helper functions)
"Simple" documentation
Custom GPTs
A ChatGPT subscription is required, and it may not be suitable for more advanced use cases or refactoring (they tend to hallucinate a lot). Consulting the documentation is recommended.
Pitfalls
None
without explicit type
let a = none;
a = opt(1);
The solution is to explicitly state the type:
let a: Opt<number> = none;
a = opt(1);
While it's recommended to not use let
, so this example may seem unrealistic, a similar issue may happen in other places as well (e.g. default arguments of functions or "exact" type via as const
).
Empty value in Some
Be careful to not misuse methods/functions like map
and end up with Opt
s like Opt<string | null>
or Opt<number | undefined>
. Such Opt
s are ticking bombs, ready to bite a coworker or later even yourself.
Let's have a simple example of a function returning a name
. When the name is missing (either whole object is undefined
or the field), we want it to return a default name, in this case 'John'
.
interface TestUser {
name?: string;
}
const getNameOrDefault = (x?: TestUser) => opt(x).map(x => x.name).orElse('John');
const nameKon = getNameOrDefault({name: 'Kon'});
const nameDefaultFromUndefined = getNameOrDefault();
It seems to be working. But there is a catch, when you pass an empty value in the name
field, you will get undefined
:
const nameDefaultFromEmpty = getNameOrDefault({});
Types can help us see, what is happening:
const getNameOrDefault = (x?: TestUser) =>
opt(x)
.map(x => x.name)
.orElse('John');
The culprit is the map
call which operates on (converts, does mapping of) the value inside the Opt
. When we want to transform a value inside an Opt
, but that transformation may return an empty value (e.g. null
or undefined
), we must not use the map
. Otherwise, advantages of Opt
are severely diminished.
Possible and most general solution is to use the chainToOpt
. It behaves same as the map
, but when empty values are returned, it flips the whole Opt
from Some
to None
.
const getNameOrDefault = (x?: TestUser) =>
opt(x)
.chainToOpt(x => x.name)
.orElse('John');
getNameOrDefault({});
Another alternative, in this case of "zooming" onto a field (which is quite common), is to use the prop
method which also automatically handles possible empty values:
const getNameOrDefault = (x?: TestUser) =>
opt(x)
.prop('name')
.orElse('John');
getNameOrDefault({});
Functional vs. imperative methods
Functional methods (functions) are used when we care about the result and passed function(s) are pure.
const res = opt(2).map(x => x * 5);
Imperative methods are used when we want to call a callback or do impure operations, we don't care about results from passed functions.
opt(1).onSome(console.log);
Functional and imperative methods can be used in one opt chain (e.g. map
-> print
-> map
) without breaking recommendations above (an example is in the map
vs onSome
section). If you have an impure part of a computation, one function, it is recommended to rewrite it to functional approach (use in map
or similar) and any remaining imperative operations (e.g. callbacks) isolate to a next part of opt chain (use onSome
or similar).
A common mistake is using a function from first column instead of one from the second.
Functional | Imperative |
---|
map / chainToOpt | onSome |
caseOf / bimap | onBoth |
orNull() || / orNull() ?? / if (!x.orUndef()) / if (x.isEmpty) | onNone |
The table isn't exhaustive, if you are unsure whether the method/function is imperative, it probably isn't (vast majority of methods are functional).
An explanation with examples can be found in the map
vs onSome
section.
map
vs onSome
At a first glance, it may look like those methods are the same.
let a = 0;
const setA = (newA: number) => { a = newA };
opt(null as number | null).map(setA);
opt(null as number | null).onSome(setA);
opt(2).map(setA);
opt(4).onSome(setA);
It may seem like in specific scenarios they can be used interchangeably (do nothing for None
and call the function for Some
). But their purpose differs quite a lot. From a technical perspective, there is a distinction in the return value.
opt(7).map(setA)
opt(9).onSome(setA)
Both methods are meant to be chained. map
allows us to refine a value inside Opt
:
opt(2)
.map(x => x + 1)
.map(x => x * x)
onSome
can't do that:
opt(2)
.onSome(x => x + 1)
.onSome(x => x * x)
That's because the purpose of onSome
is to "break" the purely functional approach and allows Opt
to be used in a more imperative way - calling a callback function for the sole purpose of side-effects. The callback function changes something somewhere else, e.g. sets a global variable or changes DOM. In such cases we don't care about the return value (in most cases it doesn't return anything, this library assumes the responsibility for an error handling is on the callback function). Thus Opt
ignores that return value from a callback and onSome
simply returns a previous Opt
. Because of this, we can easily create chains with multiple callbacks between processing:
const f = (x?: number) => opt(x)
.onSome(x => console.log('Got value', x))
.chainToOpt(x => x * x > 9 ? null : x * 2)
.onSome(x => console.log('First step result', x))
.map(x => x - 1)
.onSome(x => console.log('Second step result', x))
.orNull();
f();
f(3);
f(10);
The final note is about implementation. Since Opt
library gives a specific meaning to map
(functional methods) and onSome
(imperative methods), you should not use them interchangeably. In the future map
implementation may very well change to not be called until termination (making Opt
so called lazy), but onSome
will always force evaluation instantly (even if Opt
starts supporting lazy approach). This would mean that map
will not call a mapping function until a result from Opt
is requested (e.g. termination via orNull
).
opt(1).onSome(console.log);
opt(2).map(console.log);
const x = opt(3).map(console.log);
x.orNull();
This could lead to bugs. Ones which are not easy to track down, since evaluation of the opt may be in an entirely different file to which opt was passed across several layers and delayed (e.g. from a helper utility function via props through several React components and used [evaluated] only after a user does some action).
Generators (star functions)
Because of the limitation how yield
works, terminators like onSome
or onBoth
can't contain yield
statements. The solution is to use guard functions isSome
/isNone
.
if (x.isSome()) {
yield x.value;
}
Please note that generally guard functions isSome
and isNone
shouldn't be used where any other approach is possible.
For just checking if an opt instance is empty use isEmpty
or nonEmpty
getters.
Don't make a mistake of terminating an opt chain prematurely or do an opt unwrapping followed by an opt wrapping.
It usually leads to more noisy, less readable and less extensible code, something this library tries to improve.
Re-wrapping
Unwrapping followed by wrapping should be avoided.
In most cases there is a method or function which you can use instead.
opt(x).orNull() ? 1 : opt(x).orElse(4) * 2
opt(x).orNull() ? 1 : opt(x).orCrash('impossible') * 2
opt(x).map(x => x * 2).orElse(1)
opt(x.toUndef().?f())
x.map(y => y.f())
x.chainToOpt(y => y.f())
x.map(f)
Unclear flow of data
Generally it's best when you adhere to one direction of "data flow".
g(f(opt(x)).orElse(4)).orElse('a')
See how data flow begins at the center left 1
, then goes to the start 1, 2, 3
, then again jumps to the center 4
, then jumps to the start 5
and finally jumps to the end 6
.
You can utilize methods and functions like pipe
, mapFlow
or actToOpt
.
For example the code above could be rewritten like this:
pipe(x, opt, f, orElse(4), g, orElse('a'))
Now the data flow is easy to understand and to read, since it only flows in one direction - from left to right.
You could also use opt(x).pipe(...)
instead of pipe(x, opt, ...)
. It can lead to better type inference.
Not using specialized methods and other common bad uses
Bad | Good |
---|
lineId ? opt(lineId) : none | opt(lineId) |
tag.equals(opt('MALE')) | tag.contains('MALE') |
.map(pred).orFalse() | .exists(pred) |
.map(pred).orTrue() | .forAll(pred) |
found.prop('id').nonEmpty ? found.prop('id') : opt(id) | found.prop('id').alt(id) |
.map(...).chainToOpt(x => x) | .chainToOpt(...) |
opt(x).zip(opt(y)) | zipToOptArray([x, y]) |
Integrations
Redux DevTools
Your store setup:
import {ReduxDevtoolsCompatibilityHelper} from 'ts-opt';
import {composeWithDevTools} from 'redux-devtools-extension';
const composeEnhancers = composeWithDevTools ({
serialize: ReduxDevtoolsCompatibilityHelper,
});
const store = createStore (reducer, composeEnhancers (
applyMiddleware (...middleware),
));
Jest Snapshot Serializer
jest.config.js
:
module.exports = {
snapshotSerializers: ['ts-opt/jest-snapshot-serializer'],
};
or you could use expect.addSnapshotSerializer
in your test setup:
import optSerializer from 'ts-opt/jest-snapshot-serializer';
expect.addSnapshotSerializer(optSerializer);
Development
- clone repo
cd ts-opt
npm i
- write code
npm test
FlowLike.ts
This file is generated via program in utils/genFlowyThings
. Stack is required to run it. To regenerate the file use npm run gen-flowy
.
License
MIT