Skip to content

Commit 41922b7

Browse files
authored
Merge pull request #24 from mojotech/em/where
Add `where` method for testing constraints on a decoder
2 parents d42512a + b6f4aaf commit 41922b7

File tree

3 files changed

+165
-15
lines changed

3 files changed

+165
-15
lines changed

docs/classes/_decoder_.decoder.md

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Alternatively, the main decoder `run()` method returns an object of type `Result
3333
* [run](_decoder_.decoder.md#run)
3434
* [runPromise](_decoder_.decoder.md#runpromise)
3535
* [runWithException](_decoder_.decoder.md#runwithexception)
36+
* [where](_decoder_.decoder.md#where)
3637
* [anyJson](_decoder_.decoder.md#anyjson)
3738
* [array](_decoder_.decoder.md#array)
3839
* [boolean](_decoder_.decoder.md#boolean)
@@ -104,9 +105,11 @@ ___
104105

105106
**andThen**B(f: *`function`*): [Decoder](_decoder_.decoder.md)<`B`>
106107

107-
Chain together a sequence of decoders. The first decoder will run, and then the function will determine what decoder to run second. If the result of the first decoder succeeds then `f` will be applied to the decoded value. If it fails the error will propagate through. One use case for `andThen` is returning a custom error message.
108+
Chain together a sequence of decoders. The first decoder will run, and then the function will determine what decoder to run second. If the result of the first decoder succeeds then `f` will be applied to the decoded value. If it fails the error will propagate through.
108109

109-
Example:
110+
This is a very powerful method -- it can act as both the `map` and `where` methods, can improve error messages for edge cases, and can be used to make a decoder for custom types.
111+
112+
Example of adding an error message:
110113

111114
```
112115
const versionDecoder = valueAt(['version'], number());
@@ -128,14 +131,24 @@ decoder.run({version: 5, x: 'abc'})
128131
// =>
129132
// {
130133
// ok: false,
131-
// error: {
132-
// ...
133-
// at: 'input',
134-
// message: 'Unable to decode info, version 5 is not supported.'
135-
// }
134+
// error: {... message: 'Unable to decode info, version 5 is not supported.'}
136135
// }
137136
```
138137

138+
Example of decoding a custom type:
139+
140+
```
141+
// nominal type for arrays with a length of at least one
142+
type NonEmptyArray<T> = T[] & { __nonEmptyArrayBrand__: void };
143+
144+
const nonEmptyArrayDecoder = <T>(values: Decoder<T>): Decoder<NonEmptyArray<T>> =>
145+
array(values).andThen(arr =>
146+
arr.length > 0
147+
? succeed(createNonEmptyArray(arr))
148+
: fail(`expected a non-empty array, got an empty array`)
149+
);
150+
```
151+
139152
**Type parameters:**
140153

141154
#### B
@@ -244,6 +257,41 @@ Run the decoder and return the value on success, or throw an exception with a fo
244257

245258
**Returns:** `A`
246259

260+
___
261+
<a id="where"></a>
262+
263+
### where
264+
265+
**where**(test: *`function`*, errorMessage: *`string`*): [Decoder](_decoder_.decoder.md)<`A`>
266+
267+
Add constraints to a decoder _without_ changing the resulting type. The `test` argument is a predicate function which returns true for valid inputs. When `test` fails on an input, the decoder fails with the given `errorMessage`.
268+
269+
```
270+
const chars = (length: number): Decoder<string> =>
271+
string().where(
272+
(s: string) => s.length === length,
273+
`expected a string of length ${length}`
274+
);
275+
276+
chars(5).run('12345')
277+
// => {ok: true, result: '12345'}
278+
279+
chars(2).run('HELLO')
280+
// => {ok: false, error: {... message: 'expected a string of length 2'}}
281+
282+
chars(12).run(true)
283+
// => {ok: false, error: {... message: 'expected a string, got a boolean'}}
284+
```
285+
286+
**Parameters:**
287+
288+
| Param | Type |
289+
| ------ | ------ |
290+
| test | `function` |
291+
| errorMessage | `string` |
292+
293+
**Returns:** [Decoder](_decoder_.decoder.md)<`A`>
294+
247295
___
248296
<a id="anyjson"></a>
249297

src/decoder.ts

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -679,10 +679,13 @@ export class Decoder<A> {
679679
* Chain together a sequence of decoders. The first decoder will run, and
680680
* then the function will determine what decoder to run second. If the result
681681
* of the first decoder succeeds then `f` will be applied to the decoded
682-
* value. If it fails the error will propagate through. One use case for
683-
* `andThen` is returning a custom error message.
682+
* value. If it fails the error will propagate through.
684683
*
685-
* Example:
684+
* This is a very powerful method -- it can act as both the `map` and `where`
685+
* methods, can improve error messages for edge cases, and can be used to
686+
* make a decoder for custom types.
687+
*
688+
* Example of adding an error message:
686689
* ```
687690
* const versionDecoder = valueAt(['version'], number());
688691
* const infoDecoder3 = object({a: boolean()});
@@ -703,16 +706,51 @@ export class Decoder<A> {
703706
* // =>
704707
* // {
705708
* // ok: false,
706-
* // error: {
707-
* // ...
708-
* // at: 'input',
709-
* // message: 'Unable to decode info, version 5 is not supported.'
710-
* // }
709+
* // error: {... message: 'Unable to decode info, version 5 is not supported.'}
711710
* // }
712711
* ```
712+
*
713+
* Example of decoding a custom type:
714+
* ```
715+
* // nominal type for arrays with a length of at least one
716+
* type NonEmptyArray<T> = T[] & { __nonEmptyArrayBrand__: void };
717+
*
718+
* const nonEmptyArrayDecoder = <T>(values: Decoder<T>): Decoder<NonEmptyArray<T>> =>
719+
* array(values).andThen(arr =>
720+
* arr.length > 0
721+
* ? succeed(createNonEmptyArray(arr))
722+
* : fail(`expected a non-empty array, got an empty array`)
723+
* );
724+
* ```
713725
*/
714726
andThen = <B>(f: (value: A) => Decoder<B>): Decoder<B> =>
715727
new Decoder<B>((json: any) =>
716728
Result.andThen(value => f(value).decode(json), this.decode(json))
717729
);
730+
731+
/**
732+
* Add constraints to a decoder _without_ changing the resulting type. The
733+
* `test` argument is a predicate function which returns true for valid
734+
* inputs. When `test` fails on an input, the decoder fails with the given
735+
* `errorMessage`.
736+
*
737+
* ```
738+
* const chars = (length: number): Decoder<string> =>
739+
* string().where(
740+
* (s: string) => s.length === length,
741+
* `expected a string of length ${length}`
742+
* );
743+
*
744+
* chars(5).run('12345')
745+
* // => {ok: true, result: '12345'}
746+
*
747+
* chars(2).run('HELLO')
748+
* // => {ok: false, error: {... message: 'expected a string of length 2'}}
749+
*
750+
* chars(12).run(true)
751+
* // => {ok: false, error: {... message: 'expected a string, got a boolean'}}
752+
* ```
753+
*/
754+
where = (test: (value: A) => boolean, errorMessage: string): Decoder<A> =>
755+
this.andThen((value: A) => (test(value) ? Decoder.succeed(value) : Decoder.fail(errorMessage)));
718756
}

test/json-decode.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,6 +826,70 @@ describe('andThen', () => {
826826
});
827827
});
828828
});
829+
830+
it('creates decoders for custom types', () => {
831+
type NonEmptyArray<T> = T[] & {__nonEmptyArrayBrand__: void};
832+
const createNonEmptyArray = <T>(arr: T[]): NonEmptyArray<T> => arr as NonEmptyArray<T>;
833+
834+
const nonEmptyArrayDecoder = <T>(values: Decoder<T>): Decoder<NonEmptyArray<T>> =>
835+
array(values).andThen(
836+
arr =>
837+
arr.length > 0
838+
? succeed(createNonEmptyArray(arr))
839+
: fail(`expected a non-empty array, got an empty array`)
840+
);
841+
842+
expect(nonEmptyArrayDecoder(number()).run([1, 2, 3])).toEqual({
843+
ok: true,
844+
result: [1, 2, 3]
845+
});
846+
847+
expect(nonEmptyArrayDecoder(number()).run([])).toMatchObject({
848+
ok: false,
849+
error: {message: 'expected a non-empty array, got an empty array'}
850+
});
851+
});
852+
});
853+
854+
describe('where', () => {
855+
const chars = (length: number): Decoder<string> =>
856+
string().where((s: string) => s.length === length, `expected a string of length ${length}`);
857+
858+
const range = (min: number, max: number): Decoder<number> =>
859+
number().where(
860+
(n: number) => n >= min && n <= max,
861+
`expected a number between ${min} and ${max}`
862+
);
863+
864+
it('can test for strings of a given length', () => {
865+
expect(chars(7).run('7777777')).toEqual({ok: true, result: '7777777'});
866+
867+
expect(chars(7).run('666666')).toMatchObject({
868+
ok: false,
869+
error: {message: 'expected a string of length 7'}
870+
});
871+
});
872+
873+
it('can test for numbers in a given range', () => {
874+
expect(range(1, 9).run(7)).toEqual({ok: true, result: 7});
875+
876+
expect(range(1, 9).run(12)).toMatchObject({
877+
ok: false,
878+
error: {message: 'expected a number between 1 and 9'}
879+
});
880+
});
881+
882+
it('reports when the base decoder fails', () => {
883+
expect(chars(7).run(false)).toMatchObject({
884+
ok: false,
885+
error: {message: 'expected a string, got a boolean'}
886+
});
887+
888+
expect(range(0, 1).run(null)).toMatchObject({
889+
ok: false,
890+
error: {message: 'expected a number, got null'}
891+
});
892+
});
829893
});
830894

831895
describe('Result', () => {

0 commit comments

Comments
 (0)