Skip to content

Commit e66a42e

Browse files
committed
feat: allow passing a type to .chain
1 parent 408dd33 commit e66a42e

5 files changed

Lines changed: 146 additions & 30 deletions

File tree

.changeset/dark-suits-stand.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@badrap/valita": patch
3+
---
4+
5+
Allow passing a type to .chain()
6+
7+
The `.chain()` method of types now accepts other types as-is:
8+
9+
```ts
10+
v.string() // Accept strings as input,
11+
.map((s) => Number(s)) // then parse the strings to numbers,
12+
.chain(v.literal(1)); // and ensure that the parsed number is 1.
13+
```
14+
15+
The parsing mode is propagated to the chained type:
16+
17+
```ts
18+
const example = v.unknown().parse(v.object({ a: v.number() }));
19+
20+
example.parse({ a: 1, b: 2 }, { mode: "strip" });
21+
// { a: 1 }
22+
```

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,23 @@ CompanyString.parse('{ "name": "Acme Inc.", "ceo": "Wiley E. Coyote" }', {
651651
// { name: 'Acme Inc.' }
652652
```
653653

654+
Turns out that composing parsers like this is relatively common. Therefore you can pass types to `.chain(...)`. The following is equal to the above definition of `CompanyString`:
655+
656+
```ts
657+
const Company = v.object({ name: v.string() });
658+
659+
// We now have a handy common helper for parsing JSON strings!
660+
const JsonString = v.string().chain((json) => {
661+
try {
662+
return v.ok(JSON.parse(json));
663+
} catch {
664+
return v.err("not valid JSON");
665+
}
666+
});
667+
668+
const CompanyString = JsonString.chain(Company);
669+
```
670+
654671
### Inferring Output Types
655672

656673
The exact output type of a validator can be _inferred_ from a type validator's using with `v.Infer<typeof ...>`:

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export default [
3535
"@typescript-eslint/consistent-indexed-object-style": "off",
3636
"@typescript-eslint/prefer-return-this-type": "off",
3737
"@typescript-eslint/unbound-method": "off",
38+
"@typescript-eslint/unified-signatures": "off",
3839
"@typescript-eslint/restrict-template-expressions": [
3940
"error",
4041
{

src/index.ts

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -644,8 +644,8 @@ abstract class AbstractType<Output = unknown> {
644644
error?: CustomError,
645645
): Type<T> {
646646
const err: IssueLeaf = { ok: false, code: "custom_error", error };
647-
return new TransformType(this, (v, options) =>
648-
func(v as Output, options) ? undefined : err,
647+
return new TransformType(this, (v, flags) =>
648+
func(v as Output, flagsToOptions(flags)) ? undefined : err,
649649
);
650650
}
651651

@@ -673,9 +673,9 @@ abstract class AbstractType<Output = unknown> {
673673
): Type<T>;
674674
map<T>(func: (v: Output, options: ParseOptions) => T): Type<T>;
675675
map<T>(func: (v: Output, options: ParseOptions) => T): Type<T> {
676-
return new TransformType(this, (v, options) => ({
676+
return new TransformType(this, (v, flags) => ({
677677
ok: true,
678-
value: func(v as Output, options),
678+
value: func(v as Output, flagsToOptions(flags)),
679679
}));
680680
}
681681

@@ -712,13 +712,21 @@ abstract class AbstractType<Output = unknown> {
712712
chain<T>(
713713
func: (v: Output, options: ParseOptions) => ValitaResult<T>,
714714
): Type<T>;
715-
chain<T>(
716-
func: (v: Output, options: ParseOptions) => ValitaResult<T>,
717-
): Type<T> {
718-
return new TransformType(this, (v, options) => {
719-
const r = func(v as Output, options);
720-
return r.ok ? r : (r as unknown as { _issueTree: IssueTree })._issueTree;
721-
});
715+
chain<T>(type: Type<T>): Type<T>;
716+
chain(
717+
input: Type | ((v: Output, options: ParseOptions) => ValitaResult<unknown>),
718+
): Type {
719+
if (typeof input === "function") {
720+
return new TransformType(this, (v, flags) => {
721+
const r = input(v as Output, flagsToOptions(flags));
722+
return r.ok
723+
? r
724+
: (r as unknown as { _issueTree: IssueTree })._issueTree;
725+
});
726+
}
727+
return new TransformType(this, (v, flags) =>
728+
callMatcher(input[MATCHER_SYMBOL], v, flags),
729+
);
722730
}
723731
}
724732

@@ -1781,12 +1789,20 @@ class UnionType<T extends Type[] = Type[]> extends Type<Infer<T[number]>> {
17811789
}
17821790
}
17831791

1784-
type TransformFunc = (value: unknown, options: ParseOptions) => MatcherResult;
1792+
type TransformFunc = (value: unknown, flags: number) => MatcherResult;
17851793

17861794
const STRICT = Object.freeze({ mode: "strict" }) as ParseOptions;
17871795
const STRIP = Object.freeze({ mode: "strip" }) as ParseOptions;
17881796
const PASSTHROUGH = Object.freeze({ mode: "passthrough" }) as ParseOptions;
17891797

1798+
function flagsToOptions(flags: number): ParseOptions {
1799+
return flags & FLAG_FORBID_EXTRA_KEYS
1800+
? STRICT
1801+
: flags & FLAG_STRIP_EXTRA_KEYS
1802+
? STRIP
1803+
: PASSTHROUGH;
1804+
}
1805+
17901806
class TransformType<Output> extends Type<Output> {
17911807
readonly name = "transform";
17921808

@@ -1835,14 +1851,8 @@ class TransformType<Output> extends Type<Output> {
18351851
current = v;
18361852
}
18371853

1838-
const options =
1839-
flags & FLAG_FORBID_EXTRA_KEYS
1840-
? STRICT
1841-
: flags & FLAG_STRIP_EXTRA_KEYS
1842-
? STRIP
1843-
: PASSTHROUGH;
18441854
for (let i = 0; i < chain.length; i++) {
1845-
const r = chain[i](current, options);
1855+
const r = chain[i](current, flags);
18461856
if (r !== undefined) {
18471857
if (!r.ok) {
18481858
return r;

tests/Type.test.ts

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -232,15 +232,25 @@ describe("Type", () => {
232232
});
233233
});
234234
describe("chain", () => {
235-
it("changes the output type to the function's return type", () => {
235+
it("changes the output type to the given function's return type", () => {
236236
const _t = v.number().chain((n) => v.ok(String(n)));
237237
expectTypeOf<v.Infer<typeof _t>>().toEqualTypeOf<string>();
238238
});
239-
it("infers literals when possible", () => {
239+
240+
it("changes the output type to given type's output type", () => {
241+
const _t = v
242+
.number()
243+
.map((n) => String(n))
244+
.chain(v.literal("1"));
245+
expectTypeOf<v.Infer<typeof _t>>().toEqualTypeOf<"1">();
246+
});
247+
248+
it("infers literals as the given function's output type when possible", () => {
240249
const _t = v.number().chain(() => ({ ok: true, value: "test" }));
241250
expectTypeOf<v.Infer<typeof _t>>().toEqualTypeOf<"test">();
242251
});
243-
it("passes in the parsed value", () => {
252+
253+
it("passes in the parsed value to the given function", () => {
244254
let value: unknown;
245255
const t = v.number().chain((n) => {
246256
value = n;
@@ -249,7 +259,20 @@ describe("Type", () => {
249259
t.parse(1000);
250260
expect(value).to.equal(1000);
251261
});
252-
it("passes in normalized parse options", () => {
262+
263+
it("passes in the parsed value to the given type", () => {
264+
let value: unknown;
265+
const t = v.number().chain(
266+
v.unknown().chain((n) => {
267+
value = n;
268+
return v.ok("test");
269+
}),
270+
);
271+
t.parse(1000);
272+
expect(value).to.equal(1000);
273+
});
274+
275+
it("passes in normalized parse options to the given function", () => {
253276
let options: unknown;
254277
const t = v.number().chain((n, opts) => {
255278
options = opts;
@@ -264,11 +287,36 @@ describe("Type", () => {
264287
t.parse(1, { mode: "strict" });
265288
expect(options).to.deep.equal({ mode: "strict" });
266289
});
267-
it("passes on the success value", () => {
290+
291+
it("propagates parse options to the given type", () => {
292+
let options: unknown;
293+
const t = v.number().chain(
294+
v.unknown().chain((n, opts) => {
295+
options = opts;
296+
return v.ok("test");
297+
}),
298+
);
299+
t.parse(1, { mode: "strict" });
300+
expect(options).to.deep.equal({ mode: "strict" });
301+
t.parse(1, { mode: "strip" });
302+
expect(options).to.deep.equal({ mode: "strip" });
303+
t.parse(1, { mode: "passthrough" });
304+
expect(options).to.deep.equal({ mode: "passthrough" });
305+
t.parse(1, { mode: "strict" });
306+
expect(options).to.deep.equal({ mode: "strict" });
307+
});
308+
309+
it("passes on the success value from the given function", () => {
268310
const t = v.number().chain(() => v.ok("test"));
269311
expect(t.parse(1)).to.equal("test");
270312
});
271-
it("fails on error result", () => {
313+
314+
it("passes on the success value from the given type", () => {
315+
const t = v.number().chain(v.unknown().map(() => "test"));
316+
expect(t.parse(1)).to.equal("test");
317+
});
318+
319+
it("fails on error result from the given function", () => {
272320
const t = v.number().chain(() => v.err());
273321
expect(() => t.parse(1))
274322
.to.throw(v.ValitaError)
@@ -277,7 +325,19 @@ describe("Type", () => {
277325
code: "custom_error",
278326
});
279327
});
280-
it("allows passing in a custom error message", () => {
328+
329+
it("fails on error result from the given type", () => {
330+
const t = v.number().chain(v.string());
331+
expect(() => t.parse(1))
332+
.to.throw(v.ValitaError)
333+
.with.nested.property("issues[0]")
334+
.that.deep.includes({
335+
code: "invalid_type",
336+
expected: ["string"],
337+
});
338+
});
339+
340+
it("allows passing in a custom error message from the given function", () => {
281341
const t = v.number().chain(() => v.err("test"));
282342
expect(() => t.parse(1))
283343
.to.throw(v.ValitaError)
@@ -287,7 +347,8 @@ describe("Type", () => {
287347
error: "test",
288348
});
289349
});
290-
it("allows passing in a custom error message in an object", () => {
350+
351+
it("allows passing in a custom error message in an object from the given function", () => {
291352
const t = v.number().chain(() => v.err({ message: "test" }));
292353
expect(() => t.parse(1))
293354
.to.throw(v.ValitaError)
@@ -297,7 +358,8 @@ describe("Type", () => {
297358
error: { message: "test" },
298359
});
299360
});
300-
it("allows passing in an error path", () => {
361+
362+
it("allows passing in an error path from the given function", () => {
301363
const t = v.number().chain(() => v.err({ path: ["test"] }));
302364
expect(() => t.parse(1))
303365
.to.throw(v.ValitaError)
@@ -307,13 +369,16 @@ describe("Type", () => {
307369
path: ["test"],
308370
});
309371
});
372+
310373
it("runs multiple chains in order", () => {
311374
const t = v
312375
.string()
313376
.chain((s) => v.ok(s + "b"))
314-
.chain((s) => v.ok(s + "c"));
315-
expect(t.parse("a")).to.equal("abc");
377+
.chain((s) => v.ok(s + "c"))
378+
.chain(v.string().map((s) => s + "d"));
379+
expect(t.parse("a")).to.equal("abcd");
316380
});
381+
317382
it("works together with .try()", () => {
318383
const s = v.string();
319384
const t = v.unknown().chain((x) => s.try(x));
@@ -322,6 +387,7 @@ describe("Type", () => {
322387
expect(() => t.parse(1)).to.throw(v.ValitaError);
323388
});
324389
});
390+
325391
describe("optional", () => {
326392
it("returns an Optional", () => {
327393
expectTypeOf(v.unknown().optional()).toExtend<v.Optional>();

0 commit comments

Comments
 (0)