diff --git a/doc/v3/owl/reference/types_validation.md b/doc/v3/owl/reference/types_validation.md index 6fc1fb825..6c5ec0d24 100644 --- a/doc/v3/owl/reference/types_validation.md +++ b/doc/v3/owl/reference/types_validation.md @@ -31,6 +31,10 @@ class UserCard extends Component { The validators can also be used standalone via `validateType` and `assertType` to validate any value at runtime, not just props. +Validating never observes: the value is walked untracked and raw, so a call +made inside a `computed` or an `effect` subscribes to nothing, even when the +value is reactive. + ## `validateType` Checks a value against a validator and returns a list of validation issues. @@ -178,6 +182,35 @@ t.record(t.number()); // all values must be numbers // rejects: { a: 1, b: "two" } with t.record(t.number()) ``` +### `t.set(valueType?)` + +Validates that the value is a `Set`. When `valueType` is provided, every +element is validated against it. + +```js +t.set(); // any set +t.set(t.number()); // set of numbers + +// validates: new Set([1, 2]) with t.set(t.number()) +// rejects: new Set([1, "two"]) with t.set(t.number()) +``` + +### `t.map(keyType?, valueType?)` + +Validates that the value is a `Map`. When `keyType` is provided, every key is +validated against it, and likewise for `valueType` and the values. A failing +entry is reported by its iteration index (`0 > key`, `0 > value`), since a map +key can be any value. + +```js +t.map(); // any map +t.map(t.string()); // string keys, any value +t.map(t.string(), t.number()); // string keys, number values + +// validates: new Map([["a", 1]]) with t.map(t.string(), t.number()) +// rejects: new Map([["a", "one"]]) with t.map(t.string(), t.number()) +``` + ### `t.tuple(types)` Validates that the value is an array with a fixed length, where each element diff --git a/packages/owl-core/src/types.ts b/packages/owl-core/src/types.ts index 94853226b..7956f022a 100644 --- a/packages/owl-core/src/types.ts +++ b/packages/owl-core/src/types.ts @@ -516,6 +516,52 @@ function recordType(valueType?: any): any { }); } +function setType(): Type>; +function setType(): Type>; +function setType(valueType: V): Type>>; +function setType(valueType?: any): any { + return makeType(function validateSet(context: ValidationContext) { + if (!(context.value instanceof Set)) { + context.addIssue({ message: "value is not a set" }); + return; + } + if (!valueType) { + return; + } + let index = 0; + for (const value of context.value) { + context.withEntry(index++, value).validate(valueType); + } + }); +} + +function mapType(): Type>; +function mapType(): Type>; +function mapType(keyType: K): Type, any>>; +function mapType(keyType: K, valueType: V): Type, StripBrands>>; +function mapType(keyType?: any, valueType?: any): any { + return makeType(function validateMap(context: ValidationContext) { + if (!(context.value instanceof Map)) { + context.addIssue({ message: "value is not a map" }); + return; + } + if (!keyType && !valueType) { + return; + } + // A map key can be any value, so an entry is located by its iteration index. + let index = 0; + for (const [key, value] of context.value) { + if (keyType) { + context.withEntry([index, "key"], key).validate(keyType); + } + if (valueType) { + context.withEntry([index, "value"], value).validate(valueType); + } + index++; + } + }); +} + function tuple(types: T): Type> { const validate = makeType(function validateTuple(context: ValidationContext) { if (!Array.isArray(context.value)) { @@ -584,6 +630,7 @@ export const types = { function: functionType, instanceOf: instanceType, literal: literalType, + map: mapType, number: numberType, object: objectType, or: union, @@ -591,6 +638,7 @@ export const types = { record: recordType, ref, selection: literalSelection, + set: setType, signal: reactiveValueType, strictObject: strictObjectType, string: stringType, diff --git a/packages/owl-core/src/validation.ts b/packages/owl-core/src/validation.ts index 5a54ff2fd..06945b5c9 100644 --- a/packages/owl-core/src/validation.ts +++ b/packages/owl-core/src/validation.ts @@ -1,4 +1,6 @@ +import { untrack } from "./computations"; import { OwlError } from "./owl_error"; +import { toRaw } from "./proxy"; export interface ValidationIssue { message: string; @@ -15,6 +17,7 @@ export interface ValidationContext { path: PropertyKey[]; validate(type: any): void; value: any; + withEntry(key: PropertyKey | PropertyKey[], value: any): ValidationContext; withIssues(issues: ValidationIssue[]): ValidationContext; withKey(key: PropertyKey): ValidationContext; } @@ -65,7 +68,8 @@ function createContext( return { issueDepth: 0, path, - value, + // Walk the raw value: a proxy read builds an atom the target then keeps. + value: toRaw(value), get isValid() { return !issues.length; }, @@ -85,17 +89,20 @@ function createContext( parent.issueDepth = this.issueDepth + depthOffset; } }, + withEntry(key, value) { + return createContext(issues, value, this.path.concat(key), this); + }, withIssues(issues) { return createContext(issues, this.value, this.path, this, 0); }, withKey(key) { - return createContext(issues, this.value[key], this.path.concat(key), this); + return this.withEntry(key, this.value[key]); }, }; } export function validateType(value: any, validation: any): ValidationIssue[] { const issues: ValidationIssue[] = []; - validation(createContext(issues, value, [])); + untrack(() => validation(createContext(issues, value, []))); return issues; } diff --git a/packages/owl-core/tests/validation.test.ts b/packages/owl-core/tests/validation.test.ts index e4492f447..3c44ef6e8 100644 --- a/packages/owl-core/tests/validation.test.ts +++ b/packages/owl-core/tests/validation.test.ts @@ -4,6 +4,7 @@ import { assertType, computed, getDefault, + proxy, signal, t, types, @@ -287,6 +288,83 @@ test("literal", () => { ]); }); +describe("map", () => { + test("map", () => { + expect(validateType({}, t.map())).toEqual([ + { message: "value is not a map", path: "", received: {} }, + ]); + expect(validateType([], t.map())).toEqual([ + { message: "value is not a map", path: "", received: [] }, + ]); + expect(validateType("abc", t.map())).toEqual([ + { message: "value is not a map", path: "", received: "abc" }, + ]); + const set = new Set(["abc"]); + expect(validateType(set, t.map())).toEqual([ + { message: "value is not a map", path: "", received: set }, + ]); + expect(validateType(new Map(), t.map())).toEqual([]); + expect(validateType(new Map([["a", 123]]), t.map())).toEqual([]); + }); + + test("key and value types", () => { + const type = t.map(t.string(), t.number()); + expect(validateType(new Map(), type)).toEqual([]); + expect( + validateType( + new Map([ + ["a", 123], + ["b", 456], + ]), + type + ) + ).toEqual([]); + expect(validateType(new Map([["a", "abc"]]), type)).toEqual([ + { message: "value is not a number", path: "0 > value", received: "abc" }, + ]); + expect(validateType(new Map([[123, 123]]), type)).toEqual([ + { message: "value is not a string", path: "0 > key", received: 123 }, + ]); + expect( + validateType( + new Map([ + ["a", 123], + [456, "def"], + ]), + type + ) + ).toEqual([ + { message: "value is not a string", path: "1 > key", received: 456 }, + { message: "value is not a number", path: "1 > value", received: "def" }, + ]); + }); + + test("key type only", () => { + const type = t.map(t.string()); + expect(validateType(new Map([["a", 123]]), type)).toEqual([]); + expect(validateType(new Map([["a", "abc"]]), type)).toEqual([]); + expect(validateType(new Map([[123, "abc"]]), type)).toEqual([ + { message: "value is not a string", path: "0 > key", received: 123 }, + ]); + }); + + test("nested type", () => { + const type = t.map(t.string(), t.object({ a: t.number() })); + expect(validateType(new Map([["k", { a: 123 }]]), type)).toEqual([]); + expect(validateType(new Map([["k", { a: "abc" }]]), type)).toEqual([ + { message: "value is not a number", path: "0 > value > a", received: "abc" }, + ]); + }); + + test("a reactive map is validated like a plain one", () => { + const type = t.map(t.string(), t.number()); + expect(validateType(proxy(new Map([["a", 123]])), type)).toEqual([]); + expect(validateType(proxy(new Map([["a", "abc"]])), type)).toEqual([ + { message: "value is not a number", path: "0 > value", received: "abc" }, + ]); + }); +}); + test("number", () => { expect(validateType(123, t.number())).toEqual([]); expect(validateType(987, t.number())).toEqual([]); @@ -576,6 +654,55 @@ test("ref", () => { } }); +describe("set", () => { + test("set", () => { + expect(validateType({}, t.set())).toEqual([ + { message: "value is not a set", path: "", received: {} }, + ]); + expect(validateType([], t.set())).toEqual([ + { message: "value is not a set", path: "", received: [] }, + ]); + expect(validateType("abc", t.set())).toEqual([ + { message: "value is not a set", path: "", received: "abc" }, + ]); + const map = new Map([["a", 123]]); + expect(validateType(map, t.set())).toEqual([ + { message: "value is not a set", path: "", received: map }, + ]); + expect(validateType(new Set(), t.set())).toEqual([]); + expect(validateType(new Set(["abc", 123]), t.set())).toEqual([]); + }); + + test("value type", () => { + const type = t.set(t.string()); + expect(validateType(new Set(), type)).toEqual([]); + expect(validateType(new Set(["abc", "def"]), type)).toEqual([]); + expect(validateType(new Set([123]), type)).toEqual([ + { message: "value is not a string", path: "0", received: 123 }, + ]); + expect(validateType(new Set(["abc", 123, 456]), type)).toEqual([ + { message: "value is not a string", path: "1", received: 123 }, + { message: "value is not a string", path: "2", received: 456 }, + ]); + }); + + test("nested type", () => { + const type = t.set(t.object({ a: t.number() })); + expect(validateType(new Set([{ a: 123 }]), type)).toEqual([]); + expect(validateType(new Set([{ a: "abc" }]), type)).toEqual([ + { message: "value is not a number", path: "0 > a", received: "abc" }, + ]); + }); + + test("a reactive set is validated like a plain one", () => { + const type = t.set(t.string()); + expect(validateType(proxy(new Set(["abc"])), type)).toEqual([]); + expect(validateType(proxy(new Set([123])), type)).toEqual([ + { message: "value is not a string", path: "0", received: 123 }, + ]); + }); +}); + test("strictObject", () => { expect(validateType("", t.strictObject({}))).toEqual([ { message: "value is not an object", path: "", received: "" }, @@ -1159,3 +1286,110 @@ describe("toShape", () => { expect(shape.v).toBe(second.toShape().v); }); }); + +describe("does not observe", () => { + test("a computation validating a reactive array does not subscribe to it", () => { + const array = proxy([1, 2, 3]); + let runs = 0; + const issues = computed(() => { + runs++; + return validateType(array, t.array(t.number())); + }); + expect(issues()).toEqual([]); + expect(runs).toBe(1); + array.push(4); + expect(issues()).toEqual([]); + expect(runs).toBe(1); + array[0] = "abc"; + expect(issues()).toEqual([]); + expect(runs).toBe(1); + expect(validateType(array, t.array(t.number()))).toEqual([ + { message: "value is not a number", path: "0", received: "abc" }, + ]); + }); + + test("a computation validating a reactive object does not subscribe to it", () => { + const object = proxy>({ a: 1 }); + let runs = 0; + const issues = computed(() => { + runs++; + return validateType(object, t.record(t.number())); + }); + expect(issues()).toEqual([]); + expect(runs).toBe(1); + object.b = 2; + expect(issues()).toEqual([]); + expect(runs).toBe(1); + object.a = 3; + expect(issues()).toEqual([]); + expect(runs).toBe(1); + }); + + test("a computation validating a reactive set does not subscribe to it", () => { + const set = proxy(new Set([1, 2, 3])); + let runs = 0; + const issues = computed(() => { + runs++; + return validateType(set, t.set(t.number())); + }); + expect(issues()).toEqual([]); + expect(runs).toBe(1); + set.add(4); + expect(issues()).toEqual([]); + expect(runs).toBe(1); + set.delete(1); + expect(issues()).toEqual([]); + expect(runs).toBe(1); + }); + + test("a computation validating a reactive map does not subscribe to it", () => { + const map = proxy(new Map([["a", 1]])); + let runs = 0; + const issues = computed(() => { + runs++; + return validateType(map, t.map(t.string(), t.number())); + }); + expect(issues()).toEqual([]); + expect(runs).toBe(1); + map.set("b", 2); + expect(issues()).toEqual([]); + expect(runs).toBe(1); + map.set("a", 3); + expect(issues()).toEqual([]); + expect(runs).toBe(1); + }); + + test("a custom validator does not subscribe to a signal it reads", () => { + const limit = signal(10); + const type = t.customValidator(t.number(), (value) => value < limit()); + let runs = 0; + const issues = computed(() => { + runs++; + return validateType(5, type); + }); + expect(issues()).toEqual([]); + expect(runs).toBe(1); + limit.set(1); + expect(issues()).toEqual([]); + expect(runs).toBe(1); + expect(validateType(5, type)).toEqual([ + { message: "value does not match custom validation", path: "", received: 5 }, + ]); + }); + + test("an issue reports the raw value", () => { + const object = { a: 1 }; + expect(validateType(proxy([object]), t.array(t.string()))[0].received).toBe(object); + expect(validateType(proxy({ k: object }), t.record(t.string()))[0].received).toBe(object); + expect(validateType(proxy(new Set([object])), t.set(t.string()))[0].received).toBe(object); + const map = proxy(new Map([["k", object]])); + expect(validateType(map, t.map(t.string(), t.string()))[0].received).toBe(object); + }); + + test("a proxy held by a plain object is walked raw too", () => { + const object = { a: 1 }; + const props = { items: proxy([object]) }; + const type = t.object({ items: t.array(t.string()) }); + expect(validateType(props, type)[0].received).toBe(object); + }); +}); diff --git a/packages/owl/tests/types_collections.ts b/packages/owl/tests/types_collections.ts new file mode 100644 index 000000000..236a17c91 --- /dev/null +++ b/packages/owl/tests/types_collections.ts @@ -0,0 +1,42 @@ +// Compile-time checks for the Set and Map prop types. This file is only +// typechecked (npm run test:types); it is not executed. +import { props, t } from "../src"; + +type IsAny = boolean extends (T extends never ? true : false) ? true : false; +declare function assertNotAny(...args: IsAny extends true ? [never] : []): void; + +type Eq = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +declare function assertEq(...args: Eq extends true ? [] : [never]): void; + +class Comp { + props = props({ + anyMap: t.map(), + anySet: t.set(), + ids: t.set(t.number()), + keyed: t.map(t.string()), + scores: t.map(t.string(), t.number()), + tags: t.set(t.string()).optional(() => new Set()), + }); +} +declare const comp: Comp; +void comp; + +assertNotAny(); +assertNotAny(); + +assertEq>(); +assertEq>(); +assertEq>(); +assertEq>(); +assertEq>(); +assertEq>(); + +// the key and value types reach the members +comp.props.ids.forEach((id) => id.toFixed()); +comp.props.scores.get("a")?.toFixed(); +// @ts-expect-error a set of numbers holds no string +comp.props.ids.add("a"); +// @ts-expect-error the map values are numbers +comp.props.scores.set("a", "b"); +// @ts-expect-error the map keys are strings +comp.props.scores.get(1);