Skip to content

Commit

Permalink
Add new helpers to unwrap options recursively (#64)
Browse files Browse the repository at this point in the history
  • Loading branch information
lorisleiva authored Jun 16, 2023
1 parent e344b23 commit bc11e6e
Show file tree
Hide file tree
Showing 4 changed files with 312 additions and 22 deletions.
5 changes: 5 additions & 0 deletions .changeset/lazy-swans-promise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@metaplex-foundation/umi': patch
---

Add new helpers to unwrap options recursively
20 changes: 13 additions & 7 deletions docs/helpers.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,19 @@ isNone(some('Hello World')); // -> false
isNone(none()); // -> true

// Unwrap the value of an option if it is a `Some` or return null.
unwrapSome(some('Hello World')) // -> 'Hello World'
unwrapSome(none()) // -> null

// Unwrap the value of an option if it is a `Some`
// or return the value of the provided callback.
unwrapSomeOrElse(some('Hello World'), () => 'Default'); // -> 'Hello World'
unwrapSomeOrElse(none(), () => 'Default'); // -> 'Default'
// Supports custom fallback values for `None`.
unwrapOption(some('Hello World')) // -> 'Hello World'
unwrapOption(none()) // -> null
unwrapOption(some('Hello World'), () => 'Default'); // -> 'Hello World'
unwrapOption(none(), () => 'Default'); // -> 'Default'

// Same as `unwrapOption` but recursively (without mutating the original object/array).
// Also supports custom fallback values for `None`.
unwrapOptionRecursively({
a: 'hello',
b: none<string>(),
c: [{ c1: some(42) }, { c2: none<number>() }],
}) // -> { a: 'hello', b: null, c: [{ c1: 42 }, { c2: null }] }
```

## DateTimes
Expand Down
87 changes: 87 additions & 0 deletions packages/umi/src/Option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const isNone = <T>(option: Option<T>): option is None =>
* Otherwise, it returns `null`.
*
* @category Utils — Options
* @deprecated Use {@link unwrapOption} instead.
*/
export const unwrapSome = <T>(option: Option<T>): Nullable<T> =>
isSome(option) ? option.value : null;
Expand All @@ -77,8 +78,94 @@ export const unwrapSome = <T>(option: Option<T>): Nullable<T> =>
* Otherwise, it returns the return value of the provided fallback callback.
*
* @category Utils — Options
* @deprecated Use {@link unwrapOption} instead.
*/
export const unwrapSomeOrElse = <T, U>(
option: Option<T>,
fallback: () => U
): T | U => (isSome(option) ? option.value : fallback());

/**
* Unwraps the value of an {@link Option} of type `T`
* or returns a fallback value that defaults to `null`.
*
* @category Utils — Options
*/
export function unwrapOption<T>(option: Option<T>): Nullable<T>;
export function unwrapOption<T, U>(option: Option<T>, fallback: () => U): T | U;
export function unwrapOption<T, U = null>(
option: Option<T>,
fallback?: () => U
): T | U {
if (isSome(option)) return option.value;
return fallback ? fallback() : (null as U);
}

/**
* A type that defines the recursive unwrapping of a type `T`
* such that all nested {@link Option} types are unwrapped.
*
* For each nested {@link Option} type, if the option is a {@link Some},
* it returns the type of its value, otherwise, it returns the provided
* fallback type `U` which defaults to `null`.
*
* @category Utils — Options
*/
type UnwrappedOption<T, U = null> = T extends Some<infer TValue>
? UnwrappedOption<TValue, U>
: T extends None
? U
: T extends object
? { [key in keyof T]: UnwrappedOption<T[key], U> }
: T extends Array<infer TItem>
? Array<UnwrappedOption<TItem, U>>
: T;

/**
* Recursively go through a type `T`such that all
* nested {@link Option} types are unwrapped.
*
* For each nested {@link Option} type, if the option is a {@link Some},
* it returns its value, otherwise, it returns the provided fallback value
* which defaults to `null`.
*
* @category Utils — Options
*/
export function unwrapOptionRecursively<T>(input: T): UnwrappedOption<T>;
export function unwrapOptionRecursively<T, U>(
input: T,
fallback: () => U
): UnwrappedOption<T, U>;
export function unwrapOptionRecursively<T, U = null>(
input: T,
fallback?: () => U
): UnwrappedOption<T, U> {
// Because null passes `typeof input === 'object'`.
if (!input) return input as UnwrappedOption<T, U>;
const isOption = typeof input === 'object' && '__option' in input;
const next = <X>(x: X) =>
(fallback
? unwrapOptionRecursively(x, fallback)
: unwrapOptionRecursively(x)) as UnwrappedOption<X, U>;

// Handle None.
if (isOption && input.__option === 'None') {
return (fallback ? fallback() : null) as UnwrappedOption<T, U>;
}

// Handle Some.
if (isOption && input.__option === 'Some' && 'value' in input) {
return next(input.value) as UnwrappedOption<T, U>;
}

// Walk.
if (Array.isArray(input)) {
return input.map(next) as UnwrappedOption<T, U>;
}
if (typeof input === 'object') {
return Object.fromEntries(
Object.entries(input).map(([k, v]) => [k, next(v)])
) as UnwrappedOption<T, U>;
}
return input as UnwrappedOption<T, U>;
}
222 changes: 207 additions & 15 deletions packages/umi/test/Option.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import {
isNone,
isSome,
none,
Nullable,
Option,
some,
unwrapSome,
unwrapSomeOrElse,
unwrapOption,
unwrapOptionRecursively,
} from '../src';

test('it can create Some and None options', (t) => {
Expand Down Expand Up @@ -34,26 +35,217 @@ test('it can check if an option is Some or None', (t) => {
});

test('it can unwrap an Option as a Nullable', (t) => {
t.is(unwrapSome(some(42)), 42);
t.is(unwrapSome(some(null)), null);
t.is(unwrapSome(some('hello')), 'hello');
t.is(unwrapSome(none()), null);
t.is(unwrapSome(none<number>()), null);
t.is(unwrapSome(none<string>()), null);
t.is(unwrapOption(some(42)), 42);
t.is(unwrapOption(some(null)), null);
t.is(unwrapOption(some('hello')), 'hello');
t.is(unwrapOption(none()), null);
t.is(unwrapOption(none<number>()), null);
t.is(unwrapOption(none<string>()), null);
});

test('it can unwrap an Option using a fallback callback', (t) => {
const fallbackA = () => 42;
t.is(unwrapSomeOrElse(some(1), fallbackA), 1);
t.is(unwrapSomeOrElse(some('A'), fallbackA), 'A');
t.is(unwrapSomeOrElse(none(), fallbackA), 42);
const fallbackA = () => 42 as const;
t.is(unwrapOption(some(1), fallbackA), <number | 42>1);
t.is(unwrapOption(some('A'), fallbackA), <string | 42>'A');
t.is(unwrapOption(none(), fallbackA), <unknown | 42>42);

const fallbackB = () => {
throw new Error('Fallback Error');
};
t.is(unwrapSomeOrElse(some(1), fallbackB), 1);
t.is(unwrapSomeOrElse(some('A'), fallbackB), 'A');
t.throws(() => unwrapSomeOrElse(none(), fallbackB), {
t.is(unwrapOption(some(1), fallbackB), 1);
t.is(unwrapOption(some('A'), fallbackB), 'A');
t.throws(() => unwrapOption(none(), fallbackB), {
message: 'Fallback Error',
});
});

test('it can unwrap options recursively', (t) => {
// Some.
t.is(unwrapOptionRecursively(some(null)), null);
t.is(unwrapOptionRecursively(some(42)), <number | null>42);
t.is(unwrapOptionRecursively(some('hello')), <string | null>'hello');

// None.
t.is(unwrapOptionRecursively(none()), <unknown | null>null);
t.is(unwrapOptionRecursively(none<number>()), <number | null>null);
t.is(unwrapOptionRecursively(none<string>()), <string | null>null);

// Nested Some and None.
t.is(unwrapOptionRecursively(some(some(some(false)))), <boolean | null>false);
t.is(unwrapOptionRecursively(some(some(none<42>()))), <42 | null>null);

// Scalars.
t.is(unwrapOptionRecursively(1), 1);
t.is(unwrapOptionRecursively('hello'), 'hello');
t.is(unwrapOptionRecursively(true), true);
t.is(unwrapOptionRecursively(false), false);
t.is(unwrapOptionRecursively(null), null);
t.is(unwrapOptionRecursively(undefined), undefined);

// Functions.
const fn = () => 42;
t.is(unwrapOptionRecursively(fn), fn);

// Objects.
t.deepEqual(unwrapOptionRecursively({ foo: 'hello' }), { foo: 'hello' });
t.deepEqual(unwrapOptionRecursively({ foo: [1, true, '3'] }), {
foo: [1, true, '3'],
});
t.deepEqual(unwrapOptionRecursively({ foo: none<string>() }), { foo: null });
t.deepEqual(unwrapOptionRecursively({ foo: some(none<string>()) }), {
foo: null,
});
t.deepEqual(
unwrapOptionRecursively(some({ foo: some('bar'), baz: none<number>() })),
<Nullable<{ foo: Nullable<string>; baz: Nullable<number> }>>{
foo: 'bar',
baz: null,
}
);

// Arrays.
t.deepEqual(unwrapOptionRecursively([1, true, '3']), [1, true, '3']);
t.deepEqual(
unwrapOptionRecursively([some('a'), none<boolean>(), some(some(3)), 'b']),
['a', null, 3, 'b']
);
t.deepEqual(
unwrapOptionRecursively([
some('a'),
none<boolean>(),
some(some(3)),
'b',
] as const),
<[string | null, boolean | null, number | null, string]>['a', null, 3, 'b']
);

// Combination.
const person = {
name: 'Roo',
age: 42,
gender: none<string>(),
interests: [
{ name: 'Programming', category: some('IT') },
{ name: 'Modular Synths', category: some('Music') },
{ name: 'Popping bubble wrap', category: none<string>() },
],
address: {
street: '11215 104 Ave NW',
city: 'Edmonton',
zipcode: 'T5K 2S1',
region: some('Alberta'),
country: 'Canada',
phone: none<string>(),
},
};
const unwrappedPerson = unwrapOptionRecursively(person);
type ExpectedUnwrappedPerson = {
name: string;
age: number;
gender: string | null;
interests: Array<{ name: string; category: string | null }>;
address: {
street: string;
city: string;
zipcode: string;
region: string | null;
country: string;
phone: string | null;
};
};
t.deepEqual(unwrappedPerson, <ExpectedUnwrappedPerson>{
name: 'Roo',
age: 42,
gender: null,
interests: [
{ name: 'Programming', category: 'IT' },
{ name: 'Modular Synths', category: 'Music' },
{ name: 'Popping bubble wrap', category: null },
],
address: {
street: '11215 104 Ave NW',
city: 'Edmonton',
zipcode: 'T5K 2S1',
region: 'Alberta',
country: 'Canada',
phone: null,
},
});
});

test('it can unwrap options recursively whilst using a custom fallback', (t) => {
const fallback = () => 42 as const;

// Some.
t.is(unwrapOptionRecursively(some(null), fallback), null);
t.is(unwrapOptionRecursively(some(100), fallback), <number | 42>100);
t.is(unwrapOptionRecursively(some('hello'), fallback), <string | 42>'hello');

// None.
t.is(unwrapOptionRecursively(none(), fallback), <unknown | 42>42);
t.is(unwrapOptionRecursively(none<number>(), fallback), <number | 42>42);
t.is(unwrapOptionRecursively(none<string>(), fallback), <string | 42>42);

// Nested Some and None.
t.is(
unwrapOptionRecursively(some(some(some(false))), fallback),
<boolean | 42>false
);
t.is(
unwrapOptionRecursively(some(some(none<100>())), fallback),
<100 | 42>42
);

// Combination.
const person = {
name: 'Roo',
age: 42,
gender: none<string>(),
interests: [
{ name: 'Programming', category: some('IT') },
{ name: 'Modular Synths', category: some('Music') },
{ name: 'Popping bubble wrap', category: none<string>() },
],
address: {
street: '11215 104 Ave NW',
city: 'Edmonton',
zipcode: 'T5K 2S1',
region: some('Alberta'),
country: 'Canada',
phone: none<string>(),
},
};
const unwrappedPerson = unwrapOptionRecursively(person, fallback);
type ExpectedUnwrappedPerson = {
name: string;
age: number;
gender: string | 42;
interests: Array<{ name: string; category: string | 42 }>;
address: {
street: string;
city: string;
zipcode: string;
region: string | 42;
country: string;
phone: string | 42;
};
};
t.deepEqual(unwrappedPerson, <ExpectedUnwrappedPerson>{
name: 'Roo',
age: 42,
gender: 42,
interests: [
{ name: 'Programming', category: 'IT' },
{ name: 'Modular Synths', category: 'Music' },
{ name: 'Popping bubble wrap', category: 42 },
],
address: {
street: '11215 104 Ave NW',
city: 'Edmonton',
zipcode: 'T5K 2S1',
region: 'Alberta',
country: 'Canada',
phone: 42,
},
});
});

0 comments on commit bc11e6e

Please sign in to comment.