When considering type safety in Javascript, it's important to note that it is an untyped language. However, even in this context, thinking about types can help improve the reliability and clarity of your code.
Prioritize Error Handling
Since Javascript lacks a compiler to catch bugs before deployment, it's crucial to prioritize error handling in your code:
It's better to throw errors explicitly rather than silently ignore them.
In your function orFun
, you are working with an inclusive or type, which can lead to issues due to the unrestricted codomain. It's recommended to be transparent and throw errors when necessary, rather than returning a default value like null
which would require additional checks by the caller.
// Example of orFun implementation and usage
const orFun = p => x => y => {
const r = p(x),
s = p(y);
if (!r && !s)
throw new TypeError();
return r && s ? [x, y]
: r ? x
: y;
};
const entry = {key: 1, value: "a"},
none = {};
orFun(x => x !== undefined) (entry.key) (entry.value); // [1, "a"]
orFun(x => x !== undefined) (none.key) (none.value); // throws TypeError
Explicitly Define Cases
Another issue in your code is that opFun
returns three different types:
Number
String
[Number, String]
It's advisable to make the return types explicit to avoid ambiguity. One approach is to use a union type encoding to enforce all cases to be handled by the caller, which improves code clarity and robustness.
Implementing this style not only avoids conditional statements on the calling side but also makes your code more resilient and expressive.
The technique described above essentially involves pattern matching through continuation passing (CPS), allowing you to select the appropriate continuation based on the case at hand, debunking the notion that Javascript lacks pattern matching capabilities.
Enhancing with a Generic Implementation
To create a more versatile implementation, consider a generalized operation that constructs a These
value, accommodating scenarios where the type might not align with expectations. This operation, named toThese
, allows for greater flexibility by accepting two predicate functions and enabling error handling with default values if needed.
// Sample implementation of toThese and its usage
const toThese = (p, q, def) => x => y =>
_let((r = p(x), s = q(y)) =>
r && s ? these(x, y)
: !r && !s ? def(x) (y)
: x ? _this(x)
: that(y));
const isDefined = x => x !== undefined;
const o = {key: 1, value: "a"};
const p = {key: 1};
const q = {value: "a"};
const r = {};
const tx = toThese(isDefined, isDefined, x => y => {throw Error()}) (o.key) (o.value),
ty = toThese(isDefined, isDefined, x => y => {throw Error()}) (p.key) (p.value),
tz = toThese(isDefined, isDefined, x => y => {throw Error()}) (q.key) (q.value);
let err;
try {toThese(isDefined, isDefined, () => () => {throw new Error("type not satisfied")}) (r.key) (r.value)}
catch(e) {err = e}
// Output the results and handle any errors
console.log(tx.runThese(x => x + 1,
x => x.toUpperCase(),
(x, y) => [x + 1, y.toUpperCase()]));
console.log(ty.runThese(x => x + 1,
x => x.toUpperCase(),
(x, y) => [x + 1, y.toUpperCase()]));
console.log(tz.runThese(x => x + 1,
x => x.toUpperCase(),
(x, y) => [x + 1, y.toUpperCase()]));
throw err;