Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions doc/v3/owl/reference/props.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions doc/v3/owl/reference/types_validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/owl-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export {
type ResolveObjectType,
type ResolveOptionalEntries,
type ResolveReaderObjectType,
type ShapeType,
type StripBrands,
type Type,
type UnionToIntersection,
Expand Down
56 changes: 49 additions & 7 deletions packages/owl-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,25 @@ export type Type<T> = 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<Shape, T> = Type<T> & {
toShape(): Shape;
};

// The shape carried by an intersection member, or `never` if it has none (only
// object/intersection types expose `toShape`).
type MemberShape<M> = 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<T extends any[]> = UnionToIntersection<MemberShape<T[number]>>;

type IsAny<T> = 0 extends 1 & T ? true : false;
type HasDefault<T> = IsAny<T> extends true ? false : T extends { [hasDefault]: any } ? true : false;
type IsOptional<T> = IsAny<T> extends true ? false : T extends { [isOptional]: any } ? true : false;
Expand Down Expand Up @@ -330,13 +349,30 @@ function instanceType<T extends Constructor>(constructor: T): Type<InstanceType<
});
}

function intersection<T extends any[]>(types: T): Type<UnionToIntersection<StripBrands<T[number]>>> {
function intersection<T extends any[]>(
types: T
): ShapeType<MergedShape<T>, UnionToIntersection<StripBrands<T[number]>>> {
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<string, any> = {};
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;
}

Expand Down Expand Up @@ -413,33 +449,39 @@ function validateObject(context: ValidationContext, schema: any, isStrict: boole
}
}

function objectType(): Type<Record<string, any>>;
function objectType(): ShapeType<Record<string, any>, Record<string, any>>;
function objectType<const Keys extends string[]>(
keys: Keys
): Type<ResolveOptionalEntries<KeyedObject<Keys>>>;
function objectType<Shape extends {}>(): Type<ResolveOptionalEntries<Shape>>;
function objectType<Shape extends {}>(shape: Shape): Type<ResolveOptionalEntries<Shape>>;
): ShapeType<Keys, ResolveOptionalEntries<KeyedObject<Keys>>>;
function objectType<Shape extends {}>(): ShapeType<Shape, ResolveOptionalEntries<Shape>>;
function objectType<Shape extends {}>(
shape: Shape
): ShapeType<Shape, ResolveOptionalEntries<Shape>>;
function objectType(schema = {}): any {
const validate = makeType(function validateLooseObject(context: ValidationContext) {
validateObject(context, schema, false);
});
if (!Array.isArray(schema)) {
validate[shapeSymbol] = schema;
}
validate.toShape = () => schema;
return validate;
}

function strictObjectType<const Keys extends string[]>(
keys: Keys
): Type<ResolveOptionalEntries<KeyedObject<Keys>>>;
function strictObjectType<Shape extends {}>(shape: Shape): Type<ResolveOptionalEntries<Shape>>;
): ShapeType<Keys, ResolveOptionalEntries<KeyedObject<Keys>>>;
function strictObjectType<Shape extends {}>(
shape: Shape
): ShapeType<Shape, ResolveOptionalEntries<Shape>>;
function strictObjectType(schema: any): any {
const validate = makeType(function validateStrictObject(context: ValidationContext) {
validateObject(context, schema, true);
});
if (!Array.isArray(schema)) {
validate[shapeSymbol] = schema;
}
validate.toShape = () => schema;
return validate;
}

Expand Down
47 changes: 47 additions & 0 deletions packages/owl-core/tests/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
1 change: 1 addition & 0 deletions packages/owl-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export type {
hasDefault,
isOptional,
Optional,
ShapeType,
StripBrands,
Type,
typeBrand,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(\`<h1/>\`);

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(\`<div><block-child-0/></div>\`);

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(\`<div><block-child-0/></div>\`);

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(\`<div><block-child-0/></div>\`);

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(\`<div><block-child-0/></div>\`);

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(\`<h1/>\`);

return function template(ctx, node, key = "") {
return block1();
}
}"
`;
Loading
Loading