From 96c587f0e4e140c4801fed2dcd904d4c1e54c9a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Tue, 30 Jun 2026 11:36:38 +0200 Subject: [PATCH] [FIX] owl-core: add .toShape() to reuse a schema as props Passing a composed schema (e.g. `t.and([...])` or `t.object({...})`) to `props()` failed validation with a spurious `missingKeys: ["optional"]` error: a validator is a function carrying an `.optional` method, so `props()` mistook it for a plain `{key: Type}` shape and `Object.keys` returned `["optional"]`. Rather than teach `props()` to unwrap a validator, object schemas (`t.object`, `t.strictObject`) and intersections of them (`t.and`) now expose a `.toShape()` method returning the `{key: type}` map they were built from (`t.and` merges the shapes of its members). `props()` is left unchanged; a reusable schema drives props via `props(Schema.toShape())`, e.g. `applyDefaults(props(Schema.toShape()), Schema)`. closes #1966 --- doc/v3/owl/reference/props.md | 12 ++ doc/v3/owl/reference/types_validation.md | 27 ++++ packages/owl-core/src/index.ts | 1 + packages/owl-core/src/types.ts | 56 ++++++- packages/owl-core/tests/validation.test.ts | 47 ++++++ packages/owl-runtime/src/index.ts | 1 + .../props_validation.test.ts.snap | 120 ++++++++++++++ .../tests/components/props_validation.test.ts | 152 +++++++++++++++++- packages/owl/tests/types_defaults.ts | 30 ++++ 9 files changed, 438 insertions(+), 8 deletions(-) diff --git a/doc/v3/owl/reference/props.md b/doc/v3/owl/reference/props.md index 33637360e..d1be03cf5 100644 --- a/doc/v3/owl/reference/props.md +++ b/doc/v3/owl/reference/props.md @@ -83,6 +83,18 @@ props(["name", "age"]); // array form props({ name: t.string(), age: t.number().optional() }); // typed form ``` +To reuse a schema defined elsewhere (for instance one also passed to +[`applyDefaults`](types_validation.md#applydefaults)), pass its shape with +[`.toShape()`](types_validation.md#toshape). It is available on object schemas +([`t.object`](types_validation.md#tobjectshape), +[`t.strictObject`](types_validation.md#tstrictobjectshape)) and on intersections +of them ([`t.and`](types_validation.md#tandtypes)): + +```js +props(NotificationSchema.toShape()); // a t.object / t.strictObject schema +props(t.and([BaseSchema, ExtraSchema]).toShape()); // a composed schema +``` + ### Default values Default values are declared in the schema itself, by giving a value to the diff --git a/doc/v3/owl/reference/types_validation.md b/doc/v3/owl/reference/types_validation.md index d7fabf245..4c90e216f 100644 --- a/doc/v3/owl/reference/types_validation.md +++ b/doc/v3/owl/reference/types_validation.md @@ -328,6 +328,9 @@ t.and([t.object({ name: t.string() }), t.object({ age: t.number() })]); // rejects: { name: "Alice" } (missing "age") ``` +When every member is an object schema, [`.toShape()`](#toshape) returns the +merged shape, so a composed schema can be reused as component props. + ### `t.customValidator(type, predicate, errorMessage?)` Validates the value against a base type, then applies a custom predicate @@ -379,6 +382,30 @@ t.object({ color: t.string().optional("red") }); // rejects: "42" with the first example ("value is not a number") ``` +### `.toShape()` + +Object schemas ([`t.object`](#tobjectshape), [`t.strictObject`](#tstrictobjectshape)) +and intersections of them ([`t.and`](#tandtypes)) expose a `.toShape()` method +that returns the `{ key: type }` map the schema was built from, with the +[`.optional()`](#optionalvalue) markers intact. For an intersection, the shapes +of the members are merged (members with no shape, such as a plain +[`t.customValidator`](#tcustomvalidatortype-predicate-errormessage), are skipped). + +This lets a reusable schema drive component props without redeclaring it: +[`props()`](props.md#schema) expects a shape, so pass `schema.toShape()`. + +```js +const NotificationSchema = t.and([ + t.object({ message: t.string() }), + t.object({ sticky: t.boolean().optional(), autocloseDelay: t.number().optional(4000) }), +]); + +NotificationSchema.toShape(); +// { message: t.string(), sticky: ..., autocloseDelay: ... } + +props(NotificationSchema.toShape()); // reuse the schema as props +``` + ## Deriving a TypeScript type Every type exposes a `.type` field that carries the TypeScript type of the diff --git a/packages/owl-core/src/index.ts b/packages/owl-core/src/index.ts index bd7e2ef7e..d66b6695f 100644 --- a/packages/owl-core/src/index.ts +++ b/packages/owl-core/src/index.ts @@ -79,6 +79,7 @@ export { type ResolveObjectType, type ResolveOptionalEntries, type ResolveReaderObjectType, + type ShapeType, type StripBrands, type Type, type UnionToIntersection, diff --git a/packages/owl-core/src/types.ts b/packages/owl-core/src/types.ts index 613bd54a7..94853226b 100644 --- a/packages/owl-core/src/types.ts +++ b/packages/owl-core/src/types.ts @@ -53,6 +53,25 @@ export type Type = T & { type: T; }; +/** + * A {@link Type} that also carries the object shape it was built from, exposed + * through `toShape()`: `t.object`/`t.strictObject` return their own shape, and + * `t.and` merges the shapes of the members that have one. The shape is the + * `{ key: type }` map (with `.optional()` brands intact), so it can be reused — + * most notably to drive component props: `props(schema.toShape())`. + */ +export type ShapeType = Type & { + toShape(): Shape; +}; + +// The shape carried by an intersection member, or `never` if it has none (only +// object/intersection types expose `toShape`). +type MemberShape = M extends { toShape(): infer S } ? S : never; +// Merges the shapes of the members of an intersection that carry one, so +// `t.and(...)` exposes a single shape like a plain object schema. Overlapping +// keys intersect at the type level (the runtime merge lets later members win). +type MergedShape = UnionToIntersection>; + type IsAny = 0 extends 1 & T ? true : false; type HasDefault = IsAny extends true ? false : T extends { [hasDefault]: any } ? true : false; type IsOptional = IsAny extends true ? false : T extends { [isOptional]: any } ? true : false; @@ -330,13 +349,30 @@ function instanceType(constructor: T): Type(types: T): Type>> { +function intersection( + types: T +): ShapeType, UnionToIntersection>> { const validate = makeType(function validateIntersection(context: ValidationContext) { for (const type of types) { context.validate(type); } }); validate[intersectionSymbol] = types; + // Merge the members that carry a keyed shape (`t.object`/`t.strictObject`/ + // `t.and`), so the intersection exposes one shape usable by `props()`. Later + // members win, mirroring `applyDefaults` folding defaults in order. The + // key-list form (`t.object(["a"])`) exposes its keys as an array, which has + // no per-key types to merge, so it is skipped. + validate.toShape = () => { + const shape: Record = {}; + for (const member of types as any[]) { + const memberShape = typeof member.toShape === "function" ? member.toShape() : undefined; + if (memberShape && !Array.isArray(memberShape)) { + Object.assign(shape, memberShape); + } + } + return shape; + }; return validate; } @@ -413,12 +449,14 @@ function validateObject(context: ValidationContext, schema: any, isStrict: boole } } -function objectType(): Type>; +function objectType(): ShapeType, Record>; function objectType( keys: Keys -): Type>>; -function objectType(): Type>; -function objectType(shape: Shape): Type>; +): ShapeType>>; +function objectType(): ShapeType>; +function objectType( + shape: Shape +): ShapeType>; function objectType(schema = {}): any { const validate = makeType(function validateLooseObject(context: ValidationContext) { validateObject(context, schema, false); @@ -426,13 +464,16 @@ function objectType(schema = {}): any { if (!Array.isArray(schema)) { validate[shapeSymbol] = schema; } + validate.toShape = () => schema; return validate; } function strictObjectType( keys: Keys -): Type>>; -function strictObjectType(shape: Shape): Type>; +): ShapeType>>; +function strictObjectType( + shape: Shape +): ShapeType>; function strictObjectType(schema: any): any { const validate = makeType(function validateStrictObject(context: ValidationContext) { validateObject(context, schema, true); @@ -440,6 +481,7 @@ function strictObjectType(schema: any): any { if (!Array.isArray(schema)) { validate[shapeSymbol] = schema; } + validate.toShape = () => schema; return validate; } diff --git a/packages/owl-core/tests/validation.test.ts b/packages/owl-core/tests/validation.test.ts index 51a5c6a45..e4492f447 100644 --- a/packages/owl-core/tests/validation.test.ts +++ b/packages/owl-core/tests/validation.test.ts @@ -1112,3 +1112,50 @@ describe("applyDefaults", () => { expect(applyDefaults({}, type)).toEqual({}); }); }); + +describe("toShape", () => { + test("t.object returns the shape it was built from", () => { + const shape = { name: t.string(), age: t.number().optional() }; + expect(t.object(shape).toShape()).toBe(shape); + }); + + test("t.strictObject returns the shape it was built from", () => { + const shape = { name: t.string(), age: t.number().optional() }; + expect(t.strictObject(shape).toShape()).toBe(shape); + }); + + test("t.and merges the shapes of its members", () => { + const a = t.object({ label: t.string() }); + const b = t.object({ title: t.string().optional("x") }); + const shape = t.and([a, b]).toShape(); + expect(Object.keys(shape)).toEqual(["label", "title"]); + // the merged shape reuses the members' type validators (defaults preserved) + expect(shape.label).toBe(a.toShape().label); + expect(shape.title).toBe(b.toShape().title); + expect(getDefault(shape.title)!()).toBe("x"); + }); + + test("t.and skips members with no shape", () => { + const shape = t.and([t.object({ a: t.string() }), t.string()]).toShape(); + expect(Object.keys(shape)).toEqual(["a"]); + }); + + test("t.and skips a key-list member (no per-key types to merge)", () => { + const shape = t.and([t.object(["a", "b"]), t.object({ c: t.string() })]).toShape(); + // the key list ["a", "b"] must not leak numeric index keys into the shape + expect(Object.keys(shape)).toEqual(["c"]); + }); + + test("t.and recurses through a nested intersection", () => { + const inner = t.and([t.object({ a: t.string() }), t.object({ b: t.string() })]); + const shape = t.and([inner, t.object({ c: t.string() })]).toShape(); + expect(Object.keys(shape)).toEqual(["a", "b", "c"]); + }); + + test("later members win on overlapping keys", () => { + const first = t.object({ v: t.string() }); + const second = t.object({ v: t.number() }); + const shape = t.and([first, second]).toShape(); + expect(shape.v).toBe(second.toShape().v); + }); +}); diff --git a/packages/owl-runtime/src/index.ts b/packages/owl-runtime/src/index.ts index 3c17d71e0..782d38805 100644 --- a/packages/owl-runtime/src/index.ts +++ b/packages/owl-runtime/src/index.ts @@ -84,6 +84,7 @@ export type { hasDefault, isOptional, Optional, + ShapeType, StripBrands, Type, typeBrand, diff --git a/packages/owl-runtime/tests/components/__snapshots__/props_validation.test.ts.snap b/packages/owl-runtime/tests/components/__snapshots__/props_validation.test.ts.snap index 1ab1c4cc3..25e3b30ad 100644 --- a/packages/owl-runtime/tests/components/__snapshots__/props_validation.test.ts.snap +++ b/packages/owl-runtime/tests/components/__snapshots__/props_validation.test.ts.snap @@ -1584,3 +1584,123 @@ exports[`schema defaults > the defaults argument takes precedence over schema de } }" `; + +exports[`schema.toShape() > a composed (t.and) schema drives props via toShape() 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler } = bdom; + let { createComponent } = helpers; + const comp1 = createComponent(app, \`Notification\`, true, false, false, ["message","title"]); + + return function template(ctx, node, key = "") { + const props1 = {message: 'hello',title: 'DEBUG'}; + return comp1(props1, key + \`__1\`, node, this, null); + } +}" +`; + +exports[`schema.toShape() > a composed (t.and) schema drives props via toShape() 2`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler } = bdom; + + let block1 = createBlock(\`

\`); + + return function template(ctx, node, key = "") { + return block1(); + } +}" +`; + +exports[`schema.toShape() > a schema's props reports genuinely missing required keys 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler } = bdom; + let { createComponent } = helpers; + const comp1 = createComponent(app, \`SubComp\`, true, false, false, ["a"]); + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = "") { + const props1 = {a: 'x'}; + const b2 = comp1(props1, key + \`__1\`, node, this, null); + return block1([], [b2]); + } +}" +`; + +exports[`schema.toShape() > a schema's props still rejects invalid values 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler } = bdom; + let { createComponent } = helpers; + const comp1 = createComponent(app, \`SubComp\`, true, false, false, ["count"]); + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = "") { + const props1 = {count: 'not a number'}; + const b2 = comp1(props1, key + \`__1\`, node, this, null); + return block1([], [b2]); + } +}" +`; + +exports[`schema.toShape() > an object schema drives props via toShape() 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler } = bdom; + let { createComponent } = helpers; + const comp1 = createComponent(app, \`SubComp\`, true, false, false, ["title"]); + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = "") { + const props1 = {title: 'hello'}; + const b2 = comp1(props1, key + \`__1\`, node, this, null); + return block1([], [b2]); + } +}" +`; + +exports[`schema.toShape() > an object schema drives props via toShape() 2`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler } = bdom; + let { safeOutput } = helpers; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = "") { + const b2 = safeOutput(ctx['this'].props.title); + return block1([], [b2]); + } +}" +`; + +exports[`schema.toShape() > applyDefaults completes nested defaults from the same schema 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler } = bdom; + let { createComponent } = helpers; + const comp1 = createComponent(app, \`Notification\`, true, false, false, ["message","buttons"]); + + return function template(ctx, node, key = "") { + const props1 = {message: 'hi',buttons: [{name:'ok'}]}; + return comp1(props1, key + \`__1\`, node, this, null); + } +}" +`; + +exports[`schema.toShape() > applyDefaults completes nested defaults from the same schema 2`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler } = bdom; + + let block1 = createBlock(\`

\`); + + return function template(ctx, node, key = "") { + return block1(); + } +}" +`; diff --git a/packages/owl-runtime/tests/components/props_validation.test.ts b/packages/owl-runtime/tests/components/props_validation.test.ts index 93eb33d96..d4ba36b36 100644 --- a/packages/owl-runtime/tests/components/props_validation.test.ts +++ b/packages/owl-runtime/tests/components/props_validation.test.ts @@ -1,4 +1,4 @@ -import { Component, mount, onError, props, types as t, xml } from "../../src"; +import { applyDefaults, Component, mount, onError, props, types as t, xml } from "../../src"; import { makeTestFixture, nextTick, @@ -953,3 +953,153 @@ describe("schema defaults", () => { expect(error.message).toMatch("Invalid component default props (SubComp)"); }); }); + +//------------------------------------------------------------------------------ +// Reusing a schema as props via schema.toShape() — see #1966 +//------------------------------------------------------------------------------ + +describe("schema.toShape()", () => { + test("t.object().toShape() returns the shape it was built from", () => { + const shape = { title: t.string(), color: t.string().optional("red") }; + expect(t.object(shape).toShape()).toBe(shape); + expect(t.strictObject(shape).toShape()).toBe(shape); + }); + + test("t.and().toShape() merges the shapes of its members", () => { + const a = t.object({ message: t.string() }); + const b = t.object({ title: t.string().optional() }); + const shape = t.and([a, b]).toShape(); + expect(Object.keys(shape)).toEqual(["message", "title"]); + expect(shape.message).toBe(a.toShape().message); + expect(shape.title).toBe(b.toShape().title); + }); + + test("t.and().toShape() ignores members with no shape and recurses", () => { + const inner = t.and([t.object({ a: t.string() }), t.object({ b: t.string() })]); + const shape = t.and([inner, t.string(), t.object({ c: t.string() })]).toShape(); + // t.string() carries no shape and is skipped; the nested t.and is merged + expect(Object.keys(shape)).toEqual(["a", "b", "c"]); + }); + + test("an object schema drives props via toShape()", async () => { + let captured: any; + class SubComp extends Component { + static template = xml`
`; + props = props(t.object({ title: t.string(), color: t.string().optional("red") }).toShape()); + setup() { + captured = this.props; + } + } + class Parent extends Component { + static template = xml`
`; + static components = { SubComp }; + } + await mount(Parent, fixture, { test: true }); + expect(fixture.innerHTML).toBe("
hello
"); + // a schema default is filled, and the declared key is reactive-readable + expect(captured.color).toBe("red"); + }); + + test("a composed (t.and) schema drives props via toShape()", async () => { + // #1966: passing the composed schema itself to props() failed with a + // spurious missingKeys: ["optional"]. Passing schema.toShape() works. + const OptionSchema = t.object({ + type: t.selection(["warning", "danger"]).optional("warning"), + title: t.string().optional(), + autocloseDelay: t.number().optional(4000), + }); + const Schema = t.and([t.object({ message: t.string() }), OptionSchema]); + + let captured: any; + class Notification extends Component { + static template = xml`

`; + props = props(Schema.toShape()); + setup() { + captured = this.props; + } + } + class Root extends Component { + static components = { Notification }; + static template = xml``; + } + await mount(Root, fixture, { test: true }); + expect(captured.message).toBe("hello"); + expect(captured.title).toBe("DEBUG"); + // top-level defaults from either member of the intersection are applied + expect(captured.type).toBe("warning"); + expect(captured.autocloseDelay).toBe(4000); + }); + + test("a schema's props still rejects invalid values", async () => { + class SubComp extends Component { + static template = xml`
`; + props = props(t.object({ count: t.number() }).toShape()); + } + class Parent extends Component { + static template = xml`
`; + static components = { SubComp }; + } + let error: any; + try { + await mount(Parent, fixture, { test: true }); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.message).toMatch("Invalid component props (SubComp)"); + expect(error.message).toMatch("value is not a number"); + }); + + test("a schema's props reports genuinely missing required keys", async () => { + class SubComp extends Component { + static template = xml`
`; + props = props(t.and([t.object({ a: t.string() }), t.object({ b: t.string() })]).toShape()); + } + class Parent extends Component { + static template = xml`
`; + static components = { SubComp }; + } + let error: any; + try { + await mount(Parent, fixture, { test: true }); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.message).toMatch("Invalid component props (SubComp)"); + expect(error.message).toMatch("missing keys"); + expect(error.message).toMatch('"b"'); + // crucially, "optional" is not reported as a missing key + expect(error.message).not.toMatch('"optional"'); + }); + + test("applyDefaults completes nested defaults from the same schema", async () => { + const Schema = t.and([ + t.object({ message: t.string() }), + t.object({ + autocloseDelay: t.number().optional(4000), + buttons: t + .array(t.object({ name: t.string(), primary: t.boolean().optional(false) })) + .optional(() => []), + }), + ]); + + let captured: any; + class Notification extends Component { + static template = xml`

`; + props = applyDefaults(props(Schema.toShape()), Schema); + setup() { + captured = this.props; + } + } + class Root extends Component { + static components = { Notification }; + static template = xml``; + } + await mount(Root, fixture, { test: true }); + expect(captured.message).toBe("hi"); + expect(captured.autocloseDelay).toBe(4000); + // nested default inside the array element is filled by applyDefaults + expect(captured.buttons).toEqual([{ name: "ok", primary: false }]); + }); +}); diff --git a/packages/owl/tests/types_defaults.ts b/packages/owl/tests/types_defaults.ts index 881a48d98..b9b139360 100644 --- a/packages/owl/tests/types_defaults.ts +++ b/packages/owl/tests/types_defaults.ts @@ -102,4 +102,34 @@ function _registryCheck() { void value; } +// schema.toShape(): a reusable object schema drives props with the same reader +// view as passing the shape inline (defaulted keys required, optionals may be +// undefined). +const NotificationShape = { + message: t.string(), + className: t.string().optional("card"), + sticky: t.boolean().optional(), +}; +class FromObjectSchema { + props = props(t.object(NotificationShape).toShape()); +} +declare const fromObjectSchema: FromObjectSchema; +assertEq(); +assertEq(); +assertEq(); + +// t.and(...).toShape() merges the members' shapes into a single props shape +const ComposedSchema = t.and([ + t.object({ message: t.string() }), + t.object({ autocloseDelay: t.number().optional(4000), sticky: t.boolean().optional() }), +]); +class FromComposedSchema { + props = props(ComposedSchema.toShape()); +} +declare const fromComposedSchema: FromComposedSchema; +assertEq(); +assertEq(); +assertEq(); + void [ok1, ok2, ok3, ok4, ko1, ko2, ko3, ko4, _staticPropCheck, _configCheck, _registryCheck]; +void [fromObjectSchema, fromComposedSchema];