Conditional Methods: *If
& *Unless
#300
Replies: 3 comments
-
Here's a rudimentary implementation for import { Result, Matcher, err, ok } from 'true-myth/result';
type Known<T> = unknown extends T ? never : T;
export function andThenIf<T, E, C extends (t: T) => boolean, U, F>(
conditionFn: C,
andThenFn: (t: T) => Result<U, F>,
): Matcher<
T,
E,
C extends (t: T) => true
? Result<Known<U>, Known<F>>
: C extends (t: T) => false
? Result<T, E>
: Result<Known<T> | Known<U>, Known<E> | Known<F>>
> {
return {
Ok: (t: T) => (conditionFn(t) ? (andThenFn(t) as any) : ok<T, E>(t)),
Err: (e: E) => err<T, E>(e) as any,
};
}
export function andThenUnless<T, E, C extends (t: T) => boolean, U, F>(
conditionFn: C,
andThenFn: (t: T) => Result<U, F>,
): Matcher<
T,
E,
C extends (t: T) => false
? Result<Known<U>, Known<F>>
: C extends (t: T) => true
? Result<T, E>
: Result<Known<T> | Known<U>, Known<E> | Known<F>>
> {
return {
Ok: (t: T) => (conditionFn(t) ? ok<T, E>(t) : (andThenFn(t) as any)),
Err: (e: E) => err<T, E>(e) as any,
};
}
export function orElseIf<T, E, C extends (e: E) => boolean, U, F>(
conditionFn: C,
orElseFn: (e: E) => Result<U, F>,
): Matcher<
T,
E,
C extends (e: E) => true
? Result<Known<U>, Known<F>>
: C extends (e: E) => false
? Result<T, E>
: Result<Known<T> | Known<U>, Known<E> | Known<F>>
> {
return {
Ok: (t: T) => ok<T, E>(t) as any,
Err: (e: E) => (conditionFn(e) ? (orElseFn(e) as any) : err<T, E>(e)),
};
}
export function orElseUnless<T, E, C extends (e: E) => boolean, U, F>(
conditionFn: C,
orElseFn: (e: E) => Result<U, F>,
): Matcher<
T,
E,
C extends (e: E) => false
? Result<Known<U>, Known<F>>
: C extends (e: E) => true
? Result<T, E>
: Result<Known<T> | Known<U>, Known<E> | Known<F>>
> {
return {
Ok: (t: T) => ok<T, E>(t) as any,
Err: (e: E) => (conditionFn(e) ? err<T, E>(e) : (orElseFn(e) as any) ),
};
} const asInteger = (value: unknown, radix = 10) =>
ok(value)
.match(andThenUnless(isNumber, v => ok(Number.parseInt(String(v), radix))))
.match(andThenUnless(Number.isSafeInteger, n => err(`'${n}' is out of bounds`)))
.match(
andThenIf(
n => n.toString(radix) !== String(value),
n => err(`'${n}' does not serialize to the original value '${value}'`),
),
)
.mapErr(e => new TypeError(e)); |
Beta Was this translation helpful? Give feedback.
This comment has been hidden.
This comment has been hidden.
-
Finally had some time to think about this and I realized that the reason I've never felt the need for this is that instead of doing conditional operations like this, I more or less tend to think in terms of successive results instead. (A lot of my thinking here is very much in line with Parse, Don't Validate.) Minimally, I would tend to implement like this instead: import Result, { ok, err } from "true-myth/result";
const parseNumber = (value: unknown, radix: number): Result<number, string> => {
if (typeof value === "number") {
return ok(value);
}
if (typeof value === "string") {
let parsed = Number.parseInt(value, radix);
return !Number.isNaN(parsed) ? ok(parsed) : err("value is NaN");
}
return err(`${JSON.stringify(value)} cannot be parsed`);
};
const parseRange = (value: number): Result<number, string> =>
Number.isSafeInteger(value) ? ok(value) : err(`'${value}' is out of bounds`);
const parseInteger = (n: number, radix: number): Result<number, string> =>
n.toString(radix) === String(n)
? ok(n)
: err(`'${n}' does not serialize to the original value '${n}'`);
const asInteger = (value: unknown, radix = 10) =>
parseNumber(value, radix)
.andThen(parseRange)
.andThen((n) => parseInteger(n, radix))
.mapErr((e) => new TypeError(e)); Note that this does the same amount of actual work or less at runtime: instead of needing to actually do the check inline, the types themselves guide the system to apply the callback only when appropriate. Another way of saying it is: if you encode the results of the "validation" as types in some way instead ("parsing"!) then you can let Building on that, you could introduce a brand to actually include the parsing in the type signature: declare const Brand: unique symbol;
type Branded<T, Name> = T & { [Brand]: Name }; This lets you create types which are assignable to type Integer = Branded<number, "Integer">;
declare let i: Integer;
let x: number = i; // ✅
i = x; // ❌ This lets you encode the results you care about straight into the types, while still preserving quite a bit of generality to each "parser". Integrating all of those pieces, you end up with something like this: import Result, { ok, err } from "true-myth/result";
declare const Brand: unique symbol;
interface Opaque<Name extends symbol> {
[Brand]: Name;
}
type Branded<T, Name extends symbol> = T & Opaque<Name>;
declare const IntegerSafeRange: unique symbol;
type IntegerSafeRange = Branded<number, typeof IntegerSafeRange>;
declare const Integer: unique symbol;
type Integer = Branded<number, typeof Integer>;
const parseNumber = (value: unknown, radix: number): Result<number, string> => {
if (typeof value === "number") {
return ok(value);
}
if (typeof value === "string") {
let parsed = Number.parseInt(value, radix);
return !Number.isNaN(parsed)
? ok(parsed)
: err(`${JSON.stringify(value)} cannot be parsed as a number`);
}
return err(`${JSON.stringify(value)} cannot be parsed as a number`);
};
const parseRange = (value: number): Result<IntegerSafeRange, string> =>
Number.isSafeInteger(value)
? ok(value as IntegerSafeRange)
: err(`'${value}' is out of bounds`);
const parseInteger = (n: IntegerSafeRange, radix: number): Result<Integer, string> =>
n.toString(radix) === String(n)
? ok(n as number as Integer)
: err(`'${n}' does not serialize to the original value '${n}'`);
const asInteger = (value: unknown, radix = 10): Result<Integer, TypeError> =>
parseNumber(value, radix)
.andThen(parseRange)
.andThen((n) => parseInteger(n, radix))
.mapErr((e) => new TypeError(e)); A couple closing thoughts on that approach:
The result, though, is an |
Beta Was this translation helpful? Give feedback.
-
I was toying around with the idea of adding conditional versions of some
Result
andMaybe
methods. Only, if the condition matches, the method is actually applied.A minimum viable API would consist of the following three methods. Please note that the actual types are more complex, but were abbreviated to give a better overview. The full types are specified further down.
These methods could be amended with
*Unless
complements that invert theconditionFn
. The same concept could also be rolled out to further methods, likemap
&mapErr
.I've been yearning for such conditional methods when using
true-myth
to transform a value in a pipeline-like manner with failable steps.With today's
true-myth
, the code would look like this instead:Both examples were formatted with Prettier.
I think the proposed API offers these advantages:
... ? ok(n) : ...
mapUnless(isNumber, v => ...)
map(v => isNumber(v) ? ...)
ok<unknown, string>(value)
andThen<number>(...)
What are your thoughts? Would this only add unnecessary API surface area? Is a more concise notation already possible with the old API? Am I already approaching this wrong on a conceptual level?
Beta Was this translation helpful? Give feedback.
All reactions