@nerd-bible/valio 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,271 @@
1
+ import type { Input, Output, Result } from "./pipe";
2
+ import { type Context, HalfPipe, Pipe } from "./pipe";
3
+ import * as p from "./primitives";
4
+
5
+ class ValioArray<T> extends p.Arrayish<any[], T[]> {
6
+ constructor(public element: Pipe<any, T>) {
7
+ super(
8
+ new HalfPipe(
9
+ "array",
10
+ (v: any): v is any[] => Array.isArray(v),
11
+ (input: any[], ctx: Context): Result<T[]> => {
12
+ const output = new Array<T>(input.length);
13
+ let success = true;
14
+
15
+ const length = ctx.jsonPath.length;
16
+ for (let i = 0; i < input.length; i++) {
17
+ ctx.jsonPath[length] = i.toString();
18
+ const decoded = element.decode(input[i], ctx);
19
+ if (decoded.success) output[i] = decoded.output;
20
+ else success = false;
21
+ }
22
+ ctx.jsonPath.length = length;
23
+
24
+ if (!success) return { success, errors: ctx.errors };
25
+ return { success, output };
26
+ },
27
+ ),
28
+ new HalfPipe(`array<${element.o.name}>`, (v: any): v is T[] => {
29
+ if (!Array.isArray(v)) return false;
30
+ for (const e of v) if (!element.o.typeCheck(e)) return false;
31
+ return true;
32
+ }),
33
+ );
34
+ }
35
+ }
36
+ export function array<T>(element: Pipe<any, T>): ValioArray<T> {
37
+ return new ValioArray(element);
38
+ }
39
+
40
+ class ValioRecord<K extends PropertyKey, V> extends Pipe<
41
+ Record<any, any>,
42
+ Record<K, V>
43
+ > {
44
+ constructor(
45
+ public keyPipe: Pipe<any, K>,
46
+ public valPipe: Pipe<any, V>,
47
+ ) {
48
+ super(
49
+ new HalfPipe(
50
+ "object",
51
+ (v): v is Record<any, any> =>
52
+ Object.prototype.toString.call(v) == "[object Object]",
53
+ (input: Record<any, any>, ctx: Context): Result<Record<K, V>> => {
54
+ const output = {} as Record<K, V>;
55
+
56
+ let success = true;
57
+ const length = ctx.jsonPath.length;
58
+ for (const key in input) {
59
+ ctx.jsonPath[length] = key;
60
+ const decodedKey = keyPipe.decode(key, ctx);
61
+ if (decodedKey.success) {
62
+ const decodedVal = valPipe.decode((input as any)[key], ctx);
63
+ if (decodedVal.success) {
64
+ output[decodedKey.output] = decodedVal.output;
65
+ } else {
66
+ success = false;
67
+ }
68
+ } else {
69
+ success = false;
70
+ }
71
+ }
72
+ ctx.jsonPath.length = length;
73
+
74
+ if (!success) return { success, errors: ctx.errors };
75
+ return { success, output };
76
+ },
77
+ ),
78
+ new HalfPipe(
79
+ `record<${keyPipe.o.name},${valPipe.o.name}>`,
80
+ (v): v is Record<K, V> => {
81
+ if (Object.prototype.toString.call(v) != "[object Object]")
82
+ return false;
83
+ for (const k in v) {
84
+ // Keys will always be strings.
85
+ // if (!keyPipe.o.typeCheck(k)) return false;
86
+ if (!valPipe.o.typeCheck(v[k])) return false;
87
+ }
88
+ return true;
89
+ },
90
+ ),
91
+ );
92
+ }
93
+ }
94
+ export function record<K extends PropertyKey, V>(
95
+ keyPipe: Pipe<any, K>,
96
+ valPipe: Pipe<any, V>,
97
+ ): ValioRecord<K, V> {
98
+ return new ValioRecord(keyPipe, valPipe);
99
+ }
100
+
101
+ class Union<T extends Readonly<Pipe[]>> extends Pipe<
102
+ Output<T[number]>,
103
+ Output<T[number]>
104
+ > {
105
+ constructor(public options: T) {
106
+ const name = options.map((o) => o.o.name).join("|");
107
+ type O = Output<T[number]>;
108
+ super(
109
+ new HalfPipe(
110
+ name,
111
+ (v: any): v is O => {
112
+ for (const f of options) if (f.i.typeCheck(v)) return true;
113
+ return false;
114
+ },
115
+ (data: O, ctx: Context): Result<O> => {
116
+ const newCtx = ctx.clone();
117
+ for (const s in options) {
118
+ const decoded = options[s]!.decode(data, newCtx);
119
+ if (decoded.success) return decoded;
120
+ }
121
+
122
+ Object.assign(ctx.errors, newCtx.errors);
123
+ return { success: false, errors: ctx.errors };
124
+ },
125
+ ),
126
+ new HalfPipe(name, (v: any): v is O => {
127
+ for (const f of options) if (f.o.typeCheck(v)) return true;
128
+ return false;
129
+ }),
130
+ );
131
+ }
132
+ }
133
+ export function union<T extends Readonly<Pipe[]>>(options: T): Union<T> {
134
+ return new Union(options);
135
+ }
136
+
137
+ type ObjectOutput<Shape extends Record<string, Pipe<any, any>>> = {
138
+ [K in keyof Shape]: Output<Shape[K]>;
139
+ };
140
+ type Mask<Keys extends PropertyKey> = { [K in Keys]?: true };
141
+ type Identity<T> = T;
142
+ type Flatten<T> = Identity<{ [k in keyof T]: T[k] }>;
143
+ type Extend<A extends Record<any, any>, B extends Record<any, any>> = Flatten<
144
+ // fast path when there is no keys overlap
145
+ keyof A & keyof B extends never
146
+ ? A & B
147
+ : {
148
+ [K in keyof A as K extends keyof B ? never : K]: A[K];
149
+ } & {
150
+ [K in keyof B]: B[K];
151
+ }
152
+ >;
153
+
154
+ class ValioObject<Shape extends Record<any, Pipe<any, any>>> extends Pipe<
155
+ Record<any, any>,
156
+ ObjectOutput<Shape>
157
+ > {
158
+ constructor(
159
+ public shape: Shape,
160
+ public isLoose: boolean,
161
+ ) {
162
+ super(
163
+ new HalfPipe(
164
+ "object",
165
+ (v): v is Record<any, any> =>
166
+ Object.prototype.toString.call(v) == "[object Object]",
167
+ (data, ctx) => this.transformInput(data, ctx),
168
+ ),
169
+ new HalfPipe(
170
+ `{${Object.entries(shape)
171
+ .map(([k, v]) => `${k}: ${v.o.name}`)
172
+ .join(",")}}`,
173
+ (v) => this.typeCheckOutput(v),
174
+ ),
175
+ );
176
+ }
177
+
178
+ clone(): this {
179
+ return new ValioObject(this.shape, this.isLoose) as any;
180
+ }
181
+
182
+ protected transformInput(
183
+ data: object,
184
+ ctx: Context,
185
+ ): Result<ObjectOutput<Shape>> {
186
+ const output: Record<PropertyKey, any> = this.isLoose ? data : {};
187
+ let success = true;
188
+
189
+ const length = ctx.jsonPath.length;
190
+ // Always expect the shape since that's what typescript does.
191
+ for (const p in this.shape) {
192
+ ctx.jsonPath[length] = p;
193
+ const decoded = this.shape[p]!.decode((data as any)[p], ctx);
194
+ if (decoded.success) output[p] = decoded.output;
195
+ else {
196
+ success = false;
197
+ delete output[p];
198
+ }
199
+ }
200
+ ctx.jsonPath.length = length;
201
+
202
+ if (!success) return { success, errors: ctx.errors };
203
+ return { success, output: output as ObjectOutput<Shape> };
204
+ }
205
+
206
+ protected typeCheckOutput(v: any): v is ObjectOutput<Shape> {
207
+ if (Object.prototype.toString.call(v) != "[object Object]") return false;
208
+ for (const s in this.shape)
209
+ if (!this.shape[s]!.o.typeCheck(v[s])) return false;
210
+ return true;
211
+ }
212
+
213
+ pick<M extends Mask<keyof Shape>>(
214
+ mask: M,
215
+ ): ValioObject<Flatten<Pick<Shape, Extract<keyof Shape, keyof M>>>> {
216
+ const next = this.clone();
217
+ for (const k in next.shape) {
218
+ if (!mask[k]) delete next.shape[k];
219
+ }
220
+ return next as any;
221
+ }
222
+
223
+ omit<M extends Mask<keyof Shape>>(
224
+ mask: M,
225
+ ): ValioObject<Flatten<Omit<Shape, Extract<keyof Shape, keyof M>>>> {
226
+ const next = this.clone();
227
+ for (const k in next.shape) {
228
+ if (mask[k]) delete next.shape[k];
229
+ }
230
+ return next as any;
231
+ }
232
+
233
+ partial<M extends Mask<keyof Shape>>(
234
+ mask: M,
235
+ ): ValioObject<{
236
+ [k in keyof Shape]: k extends keyof M
237
+ ? Pipe<Input<Shape[k]>, Output<Shape[k]> | undefined>
238
+ : Shape[k];
239
+ }> {
240
+ const next = this.clone();
241
+ for (const k in next.shape) {
242
+ if (mask[k]) {
243
+ // @ts-expect-error
244
+ next.shape[k] = union([next.shape[k], p.undefined()]);
245
+ }
246
+ }
247
+ return next as any;
248
+ }
249
+
250
+ extend<T extends Record<any, Pipe<any, any>>>(
251
+ shape: T,
252
+ ): ValioObject<Extend<Shape, T>> {
253
+ const next = this.clone();
254
+ Object.assign(next.shape, shape);
255
+ return next as any;
256
+ }
257
+
258
+ loose<T = any>(
259
+ isLoose = true,
260
+ ): ValioObject<Shape & { [k: string]: Pipe<T, T> }> {
261
+ const next = this.clone();
262
+ next.isLoose = isLoose;
263
+ return next as any;
264
+ }
265
+ }
266
+ export function object<Shape extends Record<any, Pipe<any, any>>>(
267
+ shape: Shape,
268
+ loose = false,
269
+ ): ValioObject<Shape> {
270
+ return new ValioObject(shape, loose);
271
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * as codecs from "./codecs";
2
+ export * from "./containers";
3
+ export * from "./pipe";
4
+ export * from "./primitives";
@@ -0,0 +1,23 @@
1
+ const templates: Record<string, string> = {
2
+ gt: "must be > {$n}",
3
+ lt: "must be < {$n}",
4
+ gte: "must be >= {$n}",
5
+ lte: "must be <= {$n}",
6
+ eq: "must be {$n}",
7
+ minLength: "must have length <= {$n}",
8
+ maxLength: "must have length >= {$n}",
9
+ regex: "must match {$regex}",
10
+ type: "not type {$expected}",
11
+ coerce: "could not coerce to {$expected}",
12
+ };
13
+
14
+ function fmt(template: string, props: Record<any, any>) {
15
+ // You could use something like
16
+ // [MessageFormat](https://messageformat.unicode.org/) here.
17
+ return template.replace(/{\$(.*?)}/g, (_, g) => props[g]);
18
+ }
19
+
20
+ export default function format(name: string, props: Record<any, any>): string {
21
+ const template = templates[name];
22
+ return template ? fmt(template, props) : `TODO: add template for ${name}`;
23
+ }
package/src/pipe.ts ADDED
@@ -0,0 +1,172 @@
1
+ import enFormat from "./locales/en";
2
+
3
+ export type Error = { input: any; message: string };
4
+ export type Errors = { [inputPath: string]: Error[] };
5
+ export type Result<T> =
6
+ | { success: true; output: T }
7
+ | { success: false; errors: any };
8
+
9
+ function clone<T>(obj: T): T {
10
+ return Object.create(
11
+ Object.getPrototypeOf(obj),
12
+ Object.getOwnPropertyDescriptors(obj),
13
+ );
14
+ }
15
+
16
+ interface Check<T> {
17
+ valid(data: T, ctx: Context): boolean;
18
+ name: string;
19
+ props: Record<any, any>;
20
+ }
21
+
22
+ export class HalfPipe<I, O = never> {
23
+ constructor(
24
+ /** The type name */
25
+ public name: string,
26
+ /** The first check to run */
27
+ public typeCheck: (v: any) => v is I,
28
+ /** Optional transform for pipe to run at end. Useful for containers */
29
+ public transform?: (v: I, ctx: Context) => Result<O>,
30
+ ) {}
31
+ /** The second checks to run */
32
+ checks: Check<I>[] = [];
33
+
34
+ clone(): this {
35
+ const res = clone(this);
36
+ res.checks = res.checks.slice();
37
+ return res;
38
+ }
39
+ }
40
+
41
+ /** During encoding, decoding, or validation. */
42
+ export class Context {
43
+ jsonPath: (string | number)[] = [];
44
+ errors: Errors = {};
45
+
46
+ errorFmt(name: string, props: Record<any, any>): any {
47
+ return enFormat(name, props);
48
+ }
49
+
50
+ clone(): Context {
51
+ const res = clone(this);
52
+ res.jsonPath = res.jsonPath.slice();
53
+ res.errors = { ...res.errors };
54
+ return res;
55
+ }
56
+
57
+ pushError(error: Error) {
58
+ const key = `.${this.jsonPath.join(".")}`;
59
+ this.errors[key] ??= [];
60
+ this.errors[key].push(error);
61
+ }
62
+
63
+ pushErrorFmt(name: string, input: any, props: Record<any, any>) {
64
+ const message = this.errorFmt(name, props);
65
+ this.pushError({ input, message });
66
+ }
67
+
68
+ run<I, O>(input: any, halfPipe: HalfPipe<I, O>): Result<I> {
69
+ if (!halfPipe.typeCheck(input)) {
70
+ this.pushErrorFmt("type", input, { expected: halfPipe.name });
71
+ return { success: false, errors: this.errors };
72
+ }
73
+ let success = true;
74
+ for (const c of halfPipe.checks ?? []) {
75
+ if (!c.valid(input, this)) {
76
+ this.pushErrorFmt(c.name, input, c.props);
77
+ success = false;
78
+ }
79
+ }
80
+ if (!success) return { success, errors: this.errors };
81
+ return { success, output: input };
82
+ }
83
+ }
84
+
85
+ export class Pipe<I = any, O = any> {
86
+ constructor(
87
+ public i: HalfPipe<I, O>,
88
+ public o: HalfPipe<O, I>,
89
+ ) {}
90
+
91
+ pipes: Pipe<any, any>[] = [];
92
+ registry: Record<PropertyKey, any> = {};
93
+
94
+ clone(): this {
95
+ const res = clone(this);
96
+ res.i = res.i.clone();
97
+ res.o = res.o.clone();
98
+ res.pipes = res.pipes.slice();
99
+ res.registry = { ...res.registry };
100
+ return res;
101
+ }
102
+
103
+ refine(
104
+ valid: (data: O, ctx: Context) => boolean,
105
+ name: string,
106
+ props: Record<any, any>,
107
+ ): this {
108
+ const res = this.clone();
109
+ res.o.checks.push({ valid, name, props });
110
+ return res;
111
+ }
112
+
113
+ pipe<I2 extends O, O2>(pipe: Pipe<I2, O2>): Pipe<I, O2> {
114
+ const res: Pipe<any, any> = this.clone();
115
+ res.pipes.push(pipe);
116
+ return res;
117
+ }
118
+
119
+ decodeAny(input: any, ctx = new Context()): Result<O> {
120
+ // 1. Verify input
121
+ let res: Result<any> = ctx.run(input, this.i);
122
+ if (!res.success) return res;
123
+ // 2. Transform input to output
124
+ if (this.i.transform) {
125
+ res = this.i.transform(res.output, ctx);
126
+ if (!res.success) return res;
127
+ }
128
+ // 3. Verify output
129
+ res = ctx.run(res.output, this.o);
130
+ if (!res.success) return res;
131
+ // 4. Next
132
+ for (const p of this.pipes) {
133
+ res = p.decode(res.output, ctx);
134
+ if (!res.success) return res;
135
+ }
136
+ return res;
137
+ }
138
+ decode(input: I, ctx = new Context()): Result<O> {
139
+ return this.decodeAny(input, ctx);
140
+ }
141
+
142
+ encodeAny(output: any, ctx = new Context()): Result<I> {
143
+ // 1. Next
144
+ let res: Result<any> = { success: true, output };
145
+ for (let i = this.pipes.length - 1; i >= 0; i--) {
146
+ res = this.pipes[i]!.encodeAny(res.output, ctx);
147
+ if (!res.success) return res;
148
+ }
149
+ // 2. Verify output
150
+ res = ctx.run(res.output, this.o);
151
+ if (!res.success) return res;
152
+ // 3. Transform output to input
153
+ if (this.o.transform) {
154
+ res = this.o.transform(res.output, ctx);
155
+ if (!res.success) return res;
156
+ }
157
+ // 4. Verify input
158
+ return ctx.run(res.output, this.i);
159
+ }
160
+ encode(output: O, ctx = new Context()): Result<I> {
161
+ return this.encodeAny(output, ctx);
162
+ }
163
+
164
+ register(key: PropertyKey, value: any): this {
165
+ const res = this.clone();
166
+ res.registry[key] = value;
167
+ return res;
168
+ }
169
+ }
170
+
171
+ export type Input<T extends Pipe> = Parameters<T["decode"]>[0];
172
+ export type Output<T extends Pipe> = Parameters<T["encode"]>[0];
@@ -0,0 +1,82 @@
1
+ import { expect, test } from "bun:test";
2
+ import * as v from "./index";
3
+
4
+ test("number", () => {
5
+ const schema = v.number();
6
+
7
+ expect(schema.decode(5)).toEqual({ success: true, output: 5 });
8
+ expect(schema.decodeAny("5")).toEqual({
9
+ success: false,
10
+ errors: { ".": [{ input: "5", message: "not type number" }] },
11
+ });
12
+ });
13
+
14
+ test("custom validator", () => {
15
+ const schema = v.number().refine((n) => n == 5, "eq", { n: 5 });
16
+
17
+ expect(schema.decode(5)).toEqual({ success: true, output: 5 });
18
+ expect(schema.encode(3)).toEqual({
19
+ success: false,
20
+ errors: { ".": [{ input: 3, message: "must be 5" }] },
21
+ });
22
+ });
23
+
24
+ test("custom context", () => {
25
+ const schema = v.number().refine((n) => n == 5, "eq", { n: 5 });
26
+ class MyContext extends v.Context {
27
+ errorFmt() {
28
+ return "You done messed up";
29
+ }
30
+ }
31
+
32
+ expect(schema.decode(3, new MyContext())).toEqual({
33
+ success: false,
34
+ errors: { ".": [{ input: 3, message: "You done messed up" }] },
35
+ });
36
+ });
37
+
38
+ test("double min", () => {
39
+ const schema = v.number().gt(5).gt(2);
40
+
41
+ expect(schema.decode(3)).toEqual({
42
+ success: false,
43
+ errors: {
44
+ ".": [{ input: 3, message: "must be > 5" }],
45
+ },
46
+ });
47
+ expect(schema.decode(1)).toEqual({
48
+ success: false,
49
+ errors: {
50
+ ".": [
51
+ { input: 1, message: "must be > 5" },
52
+ { input: 1, message: "must be > 2" },
53
+ ],
54
+ },
55
+ });
56
+ expect(schema.decode(10)).toEqual({
57
+ success: true,
58
+ output: 10,
59
+ });
60
+ expect(schema.decodeAny("3")).toEqual({
61
+ success: false,
62
+ errors: {
63
+ ".": [
64
+ {
65
+ input: "3",
66
+ message: "not type number",
67
+ },
68
+ ],
69
+ },
70
+ });
71
+ });
72
+
73
+ test("pipe", () => {
74
+ const schema = v.string().pipe(v.number() as v.Pipe<any, number>);
75
+
76
+ expect(schema.decode("42")).toEqual({
77
+ success: false,
78
+ errors: {
79
+ ".": [{ input: "42", message: "not type number" }],
80
+ },
81
+ });
82
+ });
@@ -0,0 +1,111 @@
1
+ import { HalfPipe, Pipe } from "./pipe";
2
+
3
+ function primitive<T>(name: string, typeCheck: (v: T) => v is T) {
4
+ const half = new HalfPipe(name, typeCheck);
5
+ return new Pipe(half, half);
6
+ }
7
+
8
+ export function boolean() {
9
+ return primitive<boolean>(
10
+ "boolean",
11
+ (v): v is boolean => typeof v == "boolean",
12
+ );
13
+ }
14
+
15
+ // biome-ignore lint/suspicious/noShadowRestrictedNames: point of lib
16
+ export function undefined() {
17
+ return primitive<undefined>(
18
+ "undefined",
19
+ (v): v is undefined => typeof v == "undefined",
20
+ );
21
+ }
22
+
23
+ export function any() {
24
+ return primitive<any>("any", (v): v is any => true);
25
+ }
26
+
27
+ function null_() {
28
+ return primitive<null>("null", (v): v is null => v === null);
29
+ }
30
+ export { null_ as null };
31
+
32
+ export class Comparable<I, O> extends Pipe<I, O> {
33
+ gt(n: O) {
34
+ return this.refine((v) => v > n, "gt", { n });
35
+ }
36
+ gte(n: O) {
37
+ return this.refine((v) => v >= n, "gte", { n });
38
+ }
39
+ lt(n: O) {
40
+ return this.refine((v) => v < n, "lt", { n });
41
+ }
42
+ lte(n: O) {
43
+ return this.refine((v) => v <= n, "lte", { n });
44
+ }
45
+ eq(n: O) {
46
+ return this.refine((v) => v == n, "eq", { n });
47
+ }
48
+ }
49
+
50
+ class ValioNumber extends Comparable<number, number> {
51
+ constructor() {
52
+ const half = new HalfPipe("number", (v) => typeof v == "number");
53
+ super(half, half);
54
+ }
55
+ }
56
+ export function number() {
57
+ return new ValioNumber();
58
+ }
59
+
60
+ export class Arrayish<
61
+ I,
62
+ O extends {
63
+ length: number;
64
+ },
65
+ > extends Pipe<I, O> {
66
+ minLength(n: number) {
67
+ return this.refine((v) => v.length >= n, "minLength", { n });
68
+ }
69
+ maxLength(n: number) {
70
+ return this.refine((v) => v.length <= n, "maxLength", { n });
71
+ }
72
+ }
73
+
74
+ class ValioString extends Pipe<string, string> {
75
+ constructor() {
76
+ const half = new HalfPipe("string", (v) => typeof v == "string");
77
+ super(half, half);
78
+ }
79
+
80
+ regex(re: RegExp) {
81
+ return this.refine((v) => !!v.match(re), "regex", { regex: re.source });
82
+ }
83
+ }
84
+ export function string(): ValioString {
85
+ return new ValioString();
86
+ }
87
+
88
+ export type Lit = string | number | bigint | boolean | null | undefined;
89
+
90
+ class ValioLiteral<T extends Lit> extends Pipe<T, T> {
91
+ constructor(public literal: T) {
92
+ const half = new HalfPipe(`${literal}`, (v): v is T => v == literal);
93
+ super(half, half);
94
+ }
95
+ }
96
+ export function literal<T extends Lit>(literal: T) {
97
+ return new ValioLiteral(literal);
98
+ }
99
+
100
+ class ValioEnum<T extends Lit> extends Pipe<T, T> {
101
+ constructor(public literals: T[]) {
102
+ const half = new HalfPipe(`${literals.join(",")}`, (v: any): v is T =>
103
+ literals.includes(v),
104
+ );
105
+ super(half, half);
106
+ }
107
+ }
108
+ function enum_<T extends Lit>(literals: T[]): ValioEnum<T> {
109
+ return new ValioEnum(literals);
110
+ }
111
+ export { enum_ as enum };