From c994169dbf5c94ca69b098acbc7f6fc6be1cf6e2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:53:35 +0000 Subject: [PATCH] feat(s): add s.nullable schema helper for T | null types Adds NullableSchema type and s.nullable(inner, description?) helper that: - Serializes to {anyOf: [inner, {type: "null"}]} in JSON Schema - Validates that values are either null or match the inner schema - Infers to InferSchema | null in TypeScript - Supports an optional description parameter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- skills/rig/rig.ts | 31 ++++++++++++++++++++++++++++--- src/rig.test.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/skills/rig/rig.ts b/skills/rig/rig.ts index 39625fa..34ab2e0 100644 --- a/skills/rig/rig.ts +++ b/skills/rig/rig.ts @@ -28,7 +28,7 @@ * T:JsonSchemaObject type {[key:string]:unknown} plain JSON Schema object * s.string/number/integer/boolean/null SchemaHelperFactory primitives; call as value or fn(desc) * s.array(items,desc?) ArraySchema; use for homogeneous lists, e.g. s.array(s.string) - * s.object(props,desc?) ObjectSchema; s.optional(inner) marks field optional; use for fixed-key shapes + * s.object(props,desc?) ObjectSchema; s.optional(inner) marks field optional; s.nullable(inner) accepts inner|null; use for fixed-key shapes * s.record(valSchema,desc?) RecordSchema keyed by string; use for open-ended key→value maps * s.enum(...values|values,desc) EnumSchema * s.unknown unconstrained JSON; call as value or s.unknown("description") @@ -82,6 +82,7 @@ const OPTIONAL_SYMBOL: unique symbol = Symbol("rig.optional"); type OptionalMarker = { readonly [OPTIONAL_SYMBOL]: true }; type UnwrapOptional = Omit; export type OptionalSchema = Inner & OptionalMarker; +export type NullableSchema = { nullable: true; inner: Inner; description?: string }; export type Schema = | StringSchema @@ -94,7 +95,8 @@ export type Schema = | ObjectSchema | RecordSchema | EnumSchema - | OptionalSchema; + | OptionalSchema + | NullableSchema; type SchemaHelperFactory = T & ((description?: string) => T); @@ -174,6 +176,7 @@ export type AgentInputValue = T | PromptIntent | PromptBuilder; export type InferSchema = + T extends { nullable: true; inner: infer Inner extends Schema } ? InferSchema | null : T extends OptionalMarker ? InferSchema> | undefined : T extends { type: "string" } ? string : T extends { type: "number" } ? number : @@ -257,6 +260,20 @@ export const s = { optional(schema: Inner, description?: string): OptionalSchema { return markAsOptional(cloneSchema(schema, description)); }, + /** + * Wraps a schema to also accept `null`. The serialized JSON Schema uses `anyOf` + * with the inner schema and `{"type":"null"}`. The inferred TypeScript type is + * `InferSchema | null`. + * + * @example + * s.nullable(s.string) // string | null + * s.nullable(s.number, "score or null") // number | null with description + */ + nullable(schema: Inner, description?: string): NullableSchema { + return markAsSchema(description !== undefined + ? { nullable: true, inner: schema, description } + : { nullable: true, inner: schema }); + }, /** Converts a rig `Schema` to a plain JSON Schema object. */ toJsonSchema, }; @@ -268,9 +285,13 @@ export function toJsonSchema(schema: Schema): JsonSchemaObject { } function serializeSchema(schema: Schema): JsonSchemaObject { - const { description } = schema; + const { description } = schema as { description?: string }; const withDescription = (obj: JsonSchemaObject): JsonSchemaObject => description === undefined ? obj : { ...obj, description }; + if ("nullable" in schema && schema.nullable === true) { + const inner = serializeSchema((schema as NullableSchema).inner); + return withDescription({ anyOf: [inner, { type: "null" }] }); + } if ("enum" in schema) { const enumValues = schema.enum as readonly unknown[]; const allStrings = enumValues.length > 0 && enumValues.every((v) => typeof v === "string"); @@ -1381,6 +1402,10 @@ function validateSchema(value: unknown, schema: Schema, path: string, optional: if ((optional || isOptionalSchema(schema)) && value === undefined) { return { ok: true }; } + if ("nullable" in schema && schema.nullable === true) { + if (value === null) return ok(); + return validateSchema(value, (schema as NullableSchema).inner, path, false); + } if ("enum" in schema) { return schema.enum.some((item: Json) => deepEqual(item, value)) ? ok() diff --git a/src/rig.test.ts b/src/rig.test.ts index b59cdfe..741323d 100644 --- a/src/rig.test.ts +++ b/src/rig.test.ts @@ -1085,6 +1085,40 @@ describe("toJsonSchema", () => { }); }); +describe("s.nullable", () => { + it("serializes to anyOf with null", () => { + expect(toJsonSchema(s.nullable(s.string))).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] }); + expect(toJsonSchema(s.nullable(s.number))).toEqual({ anyOf: [{ type: "number" }, { type: "null" }] }); + }); + + it("includes description when provided", () => { + expect(toJsonSchema(s.nullable(s.string, "maybe text"))).toEqual({ + anyOf: [{ type: "string" }, { type: "null" }], + description: "maybe text", + }); + }); + + it("accepts null values during validation", async () => { + mocks.setSendAndWaitImpl(async () => JSON.stringify({ score: null })); + const a = agent({ output: s.object({ score: s.nullable(s.number) }) }); + const result = await a(""); + expect(result).toEqual({ score: null }); + }); + + it("accepts non-null values during validation", async () => { + mocks.setSendAndWaitImpl(async () => JSON.stringify({ score: 42 })); + const a = agent({ output: s.object({ score: s.nullable(s.number) }) }); + const result = await a(""); + expect(result).toEqual({ score: 42 }); + }); + + it("rejects invalid values that are neither null nor inner type", async () => { + mocks.setSendAndWaitImpl(async () => JSON.stringify({ score: "not-a-number" })); + const a = agent({ output: s.object({ score: s.nullable(s.number) }), maxTurns: 1 }); + await expect(a("")).rejects.toBeInstanceOf(AgentError); + }); +}); + describe("s.integer validation", () => { it("accepts whole numbers", () => { const intAgent = agent({ output: s.integer });