mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(agent): pi-schema's Static types TypeBox-built schemas too, without depending on TypeBox
This commit is contained in:
parent
a6bde34bb6
commit
6291b9bcc3
12 changed files with 304 additions and 10 deletions
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
||||||
- `AgentTool` takes any JSON Schema object as `parameters` (`AgentTool<TParameters extends object>`) and types `params` and `prepareArguments` through `@earendil-works/pi-schema` (`AgentToolParams<TParameters>`): pi-schema's builders give their static type, other schemas (TypeBox included) give `unknown`, `AgentTool<any>` stays `any`. `outputSchema` is any JSON Schema object. `typebox` is no longer a dependency. Migration: build parameters with pi-schema (`obj({ path: str() })` instead of `Type.Object({ path: Type.String() })`), or keep TypeBox schemas and narrow `params` yourself.
|
- `AgentTool` takes any JSON Schema object as `parameters` (`AgentTool<TParameters extends object>`) and types `params` and `prepareArguments` through `@earendil-works/pi-schema` (`AgentToolParams<TParameters>`): pi-schema's builders give their static type, TypeBox schemas give TypeBox's static type for the common kinds (String, Number, Integer, Boolean, Null, Literal, Array, Object with optional and readonly properties, Union, Record, Unsafe, Unknown, Any; other kinds such as Enum, Tuple and Intersect give `unknown`), other schemas give `unknown`, `AgentTool<any>` stays `any`. `outputSchema` is any JSON Schema object. `typebox` is no longer a dependency. Migration: build parameters with pi-schema (`obj({ path: str() })` instead of `Type.Object({ path: Type.String() })`), or keep TypeBox schemas (narrow `params` yourself only where they use other kinds).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -463,8 +463,8 @@ export interface AgentToolCallOutcome {
|
||||||
export type AgentToolUpdateCallback<T = any> = (partialResult: AgentToolResult<T>) => void;
|
export type AgentToolUpdateCallback<T = any> = (partialResult: AgentToolResult<T>) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The arguments type of a tool with parameter schema `TParameters`: `Static<TParameters>` for schemas built with
|
* The arguments type of a tool with parameter schema `TParameters`: pi-schema's `Static<TParameters>` (the builders'
|
||||||
* `@earendil-works/pi-schema`'s builders, `unknown` for other schemas, and `any` for `AgentTool<any>`.
|
* type, TypeBox's for the common TypeBox kinds, else `unknown`), and `any` for `AgentTool<any>`.
|
||||||
*/
|
*/
|
||||||
export type AgentToolParams<TParameters> = IsAny<TParameters> extends true ? any : Static<TParameters>;
|
export type AgentToolParams<TParameters> = IsAny<TParameters> extends true ? any : Static<TParameters>;
|
||||||
|
|
||||||
|
|
|
||||||
65
packages/agent/test/agent-tool-typebox.test.ts
Normal file
65
packages/agent/test/agent-tool-typebox.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
// AgentTool params typed from a TypeBox-built parameter schema. pi-agent-core does not depend on TypeBox: the schema
|
||||||
|
// below has TypeBox 1.x's type shapes as its d.mts declare them; pi-schema's own tests check the shapes against TypeBox.
|
||||||
|
|
||||||
|
import { describe, expectTypeOf, it } from "vitest";
|
||||||
|
import type { AgentTool } from "../src/types.ts";
|
||||||
|
|
||||||
|
interface TString {
|
||||||
|
"~kind": "String";
|
||||||
|
type: "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TLiteral<Value> {
|
||||||
|
"~kind": "Literal";
|
||||||
|
const: Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TUnion<Branches> {
|
||||||
|
"~kind": "Union";
|
||||||
|
anyOf: Branches;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TObject<Properties> {
|
||||||
|
"~kind": "Object";
|
||||||
|
type: "object";
|
||||||
|
properties: Properties;
|
||||||
|
required: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type TOptional<Schema> = Schema & { "~optional": true };
|
||||||
|
|
||||||
|
const parameters: TObject<{ command: TString; mode: TOptional<TUnion<[TLiteral<"fast">, TLiteral<"deep">]>> }> = {
|
||||||
|
"~kind": "Object",
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
command: { "~kind": "String", type: "string" },
|
||||||
|
mode: {
|
||||||
|
"~kind": "Union",
|
||||||
|
anyOf: [
|
||||||
|
{ "~kind": "Literal", const: "fast" },
|
||||||
|
{ "~kind": "Literal", const: "deep" },
|
||||||
|
],
|
||||||
|
"~optional": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["command"],
|
||||||
|
};
|
||||||
|
|
||||||
|
type Params = { command: string; mode?: "fast" | "deep" };
|
||||||
|
|
||||||
|
describe("AgentTool params of a TypeBox schema", () => {
|
||||||
|
it("are typed by the schema in execute() and prepareArguments()", () => {
|
||||||
|
const tool: AgentTool<typeof parameters> = {
|
||||||
|
name: "bash",
|
||||||
|
label: "Bash",
|
||||||
|
description: "Runs a command",
|
||||||
|
parameters,
|
||||||
|
async execute(_toolCallId, params) {
|
||||||
|
expectTypeOf(params).toEqualTypeOf<Params>();
|
||||||
|
return { content: [], details: undefined };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expectTypeOf<Parameters<typeof tool.execute>[1]>().toEqualTypeOf<Params>();
|
||||||
|
expectTypeOf<ReturnType<NonNullable<typeof tool.prepareArguments>>>().toEqualTypeOf<Params>();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,8 +4,8 @@ import type { ExtensionToolContext, ToolDefinition } from "../extensions/types.t
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An `AgentTool` with a TypeBox parameter schema whose `execute` and `prepareArguments` are typed by TypeBox's
|
* An `AgentTool` with a TypeBox parameter schema whose `execute` and `prepareArguments` are typed by TypeBox's
|
||||||
* `Static`, as the built-in tools are (pi-agent-core types `params` through pi-schema, which gives `unknown` for
|
* `Static`, as the built-in tools are (pi-agent-core types `params` through pi-schema, which maps only the common
|
||||||
* TypeBox schemas). Spreads into a `ToolDefinition` of the same schema.
|
* TypeBox kinds). Spreads into a `ToolDefinition` of the same schema.
|
||||||
*/
|
*/
|
||||||
export type TypeBoxAgentTool<TParams extends TSchema = TSchema, TDetails = any> = Omit<
|
export type TypeBoxAgentTool<TParams extends TSchema = TSchema, TDetails = any> = Omit<
|
||||||
AgentTool<TParams, TDetails>,
|
AgentTool<TParams, TDetails>,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
||||||
- Tool schemas are JSON Schema typed through `@earendil-works/pi-schema`: `defineTool()` and `ToolRegistration` take any JSON Schema object as `parameters` and `structuredOutputSchema`, and type `args` with pi-schema's `Static` (`ToolArgs<TParameters>`; other schemas give `unknown`). The built-in tools (`read`, `write`, `edit`, `bash`, `powershell`, `codemode`, `tool_search`, the model tools) are built with pi-schema, with the same JSON Schema up to key order. `structuredOutput` is validated with pi-schema, so `invalid_structured_output` messages use its wording. `typebox` is no longer a dependency. Migration: replace `Type.Object({ n: Type.Number() })` from `@earendil-works/pi-ai` with `obj({ n: num() })` from `@earendil-works/pi-schema` (closed by default; `{ open: true }` allows other properties).
|
- Tool schemas are JSON Schema typed through `@earendil-works/pi-schema`: `defineTool()` and `ToolRegistration` take any JSON Schema object as `parameters` and `structuredOutputSchema`, and type `args` with pi-schema's `Static` (`ToolArgs<TParameters>`; TypeBox schemas give TypeBox's static type for the common kinds, as pi-schema's `Static` describes; other schemas give `unknown`). The built-in tools (`read`, `write`, `edit`, `bash`, `powershell`, `codemode`, `tool_search`, the model tools) are built with pi-schema, with the same JSON Schema up to key order. `structuredOutput` is validated with pi-schema, so `invalid_structured_output` messages use its wording. `typebox` is no longer a dependency. Migration: replace `Type.Object({ n: Type.Number() })` from `@earendil-works/pi-ai` with `obj({ n: num() })` from `@earendil-works/pi-schema` (closed by default; `{ open: true }` allows other properties).
|
||||||
- `SqliteExecutor` (re-exported from Chord) gains `runCounted(sql, ...params)`, resolving with the rows the statement changed; custom `SqliteDatabase` implementations add it.
|
- `SqliteExecutor` (re-exported from Chord) gains `runCounted(sql, ...params)`, resolving with the rows the statement changed; custom `SqliteDatabase` implementations add it.
|
||||||
- `Storage.commit()` stores a group of commits in one call: `commit(batches, receive, context)` resolves with a `CommitResult` (`{ stored, refused? }`) and replaces `commit(writes, adopt, context)`. Each batch is one atomic commit with its own seq, checked against the state the batches before it left. The first batch Durable's own checks refuse (global IDs, document actions, copy sources) is not stored, nor anything after it; the batches before it are, and the call resolves with their number in `stored` and the refusal in `refused`. Any other failure (a driver or I/O error, a failed `COMMIT`) rejects the whole call as an unknown outcome and stores nothing the backend could roll back. `receive(index, seq)` is called once per stored batch, in order, synchronously, once they are durable and before the next call starts; receipts made before a rejection stand. Every batch has at least one write. The optional `publishing(publish, context)` runs each commit's publication inside the storage's own scope (`StoreStorage`: the attached host's batch). Memory, SQLite, JSONL, IndexedDB and `StoreStorage` store a call in one transaction or append, and the storage conformance suite checks the contract. Migration for a custom Storage:
|
- `Storage.commit()` stores a group of commits in one call: `commit(batches, receive, context)` resolves with a `CommitResult` (`{ stored, refused? }`) and replaces `commit(writes, adopt, context)`. Each batch is one atomic commit with its own seq, checked against the state the batches before it left. The first batch Durable's own checks refuse (global IDs, document actions, copy sources) is not stored, nor anything after it; the batches before it are, and the call resolves with their number in `stored` and the refusal in `refused`. Any other failure (a driver or I/O error, a failed `COMMIT`) rejects the whole call as an unknown outcome and stores nothing the backend could roll back. `receive(index, seq)` is called once per stored batch, in order, synchronously, once they are durable and before the next call starts; receipts made before a rejection stand. Every batch has at least one write. The optional `publishing(publish, context)` runs each commit's publication inside the storage's own scope (`StoreStorage`: the attached host's batch). Memory, SQLite, JSONL, IndexedDB and `StoreStorage` store a call in one transaction or append, and the storage conformance suite checks the contract. Migration for a custom Storage:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -333,8 +333,8 @@ export interface ToolExecutionApi<TDetails extends JsonValue = JsonValue> extend
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The arguments type of a tool with parameter schema `TParameters`: `Static<TParameters>` for schemas built with
|
* The arguments type of a tool with parameter schema `TParameters`: pi-schema's `Static<TParameters>` (the builders'
|
||||||
* `@earendil-works/pi-schema`'s builders, `unknown` for other JSON Schema, and `any` for `ToolRegistration<any>`.
|
* type, TypeBox's for the common TypeBox kinds, else `unknown`), and `any` for `ToolRegistration<any>`.
|
||||||
*/
|
*/
|
||||||
export type ToolArgs<TParameters> = IsAny<TParameters> extends true ? any : Static<TParameters>;
|
export type ToolArgs<TParameters> = IsAny<TParameters> extends true ? any : Static<TParameters>;
|
||||||
|
|
||||||
|
|
|
||||||
61
packages/durable/test/tool-args-typebox.test.ts
Normal file
61
packages/durable/test/tool-args-typebox.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// Tool arguments typed from a TypeBox-built parameter schema. Durable does not depend on TypeBox: the schema below
|
||||||
|
// has TypeBox 1.x's type shapes as its d.mts declare them; pi-schema's own tests check the shapes against TypeBox.
|
||||||
|
|
||||||
|
import { defineTool, type ToolRegistration } from "@earendil-works/pi-durable";
|
||||||
|
import { describe, expectTypeOf, it } from "vitest";
|
||||||
|
|
||||||
|
interface TString {
|
||||||
|
"~kind": "String";
|
||||||
|
type: "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TInteger {
|
||||||
|
"~kind": "Integer";
|
||||||
|
type: "integer";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TArray<Items> {
|
||||||
|
"~kind": "Array";
|
||||||
|
type: "array";
|
||||||
|
items: Items;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TObject<Properties> {
|
||||||
|
"~kind": "Object";
|
||||||
|
type: "object";
|
||||||
|
properties: Properties;
|
||||||
|
required: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type TOptional<Schema> = Schema & { "~optional": true };
|
||||||
|
|
||||||
|
const parameters: TObject<{ path: TString; offset: TOptional<TInteger>; tags: TArray<TString> }> = {
|
||||||
|
"~kind": "Object",
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
path: { "~kind": "String", type: "string" },
|
||||||
|
offset: { "~kind": "Integer", type: "integer", "~optional": true },
|
||||||
|
tags: { "~kind": "Array", type: "array", items: { "~kind": "String", type: "string" } },
|
||||||
|
},
|
||||||
|
required: ["path", "tags"],
|
||||||
|
};
|
||||||
|
|
||||||
|
type Args = { path: string; offset?: number; tags: string[] };
|
||||||
|
|
||||||
|
describe("tool arguments of a TypeBox schema", () => {
|
||||||
|
it("are typed by the schema in execute() and prepareArguments()", () => {
|
||||||
|
const tool = defineTool({
|
||||||
|
name: "read",
|
||||||
|
description: "Reads a file",
|
||||||
|
parameters,
|
||||||
|
async execute(args) {
|
||||||
|
expectTypeOf(args).toEqualTypeOf<Args>();
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expectTypeOf<Parameters<typeof tool.execute>[0]>().toEqualTypeOf<Args>();
|
||||||
|
expectTypeOf<
|
||||||
|
ReturnType<NonNullable<ToolRegistration<typeof parameters>["prepareArguments"]>>
|
||||||
|
>().toEqualTypeOf<Args>();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
- Initial release: `compile(schema, options)` validates JSON Schema drafts 2020-12 and 7 without `eval` or `new Function`, compiling a schema once into plain closures. `Validator.check` is the boolean check (a type guard for schemas built with the builders), `Validator.errors` reports each failure with a JSON Pointer path. Format checks are in the optional `@earendil-works/pi-schema/formats` entry point.
|
- Initial release: `compile(schema, options)` validates JSON Schema drafts 2020-12 and 7 without `eval` or `new Function`, compiling a schema once into plain closures. `Validator.check` is the boolean check (a type guard for schemas built with the builders), `Validator.errors` reports each failure with a JSON Pointer path. Format checks are in the optional `@earendil-works/pi-schema/formats` entry point.
|
||||||
- Schema builders (`str`, `num`, `int`, `bool`, `nul`, `unknown`, `lit`, `oneOf`, `obj`, `opt`, `arr`, `tuple`, `map`, `union`, `nullable`, `bytes`) that return plain JSON Schema typed with the values it accepts (`Static<typeof schema>`).
|
- Schema builders (`str`, `num`, `int`, `bool`, `nul`, `unknown`, `lit`, `oneOf`, `obj`, `opt`, `arr`, `tuple`, `map`, `union`, `nullable`, `bytes`) that return plain JSON Schema typed with the values it accepts (`Static<typeof schema>`).
|
||||||
- `compile(schema, { coerce: true })` adds `coerce(value)` and `parse(value)` for loosely typed input such as a model's tool call arguments: optional `null`s dropped, TypeBox 1.3.27's `Value.Convert` rules keyed on JSON Schema keywords (numeric strings, `"true"`, single values to arrays, `const`/`enum` members, union branches), then Ajv-style primitive coercion by `type`. `coerce` copies only the paths it changes and returns the input itself when nothing changes; `parse` coerces, then checks. Validators compiled without the option have neither; the README lists every rule.
|
- `compile(schema, { coerce: true })` adds `coerce(value)` and `parse(value)` for loosely typed input such as a model's tool call arguments: optional `null`s dropped, TypeBox 1.3.27's `Value.Convert` rules keyed on JSON Schema keywords (numeric strings, `"true"`, single values to arrays, `const`/`enum` members, union branches), then Ajv-style primitive coercion by `type`. `coerce` copies only the paths it changes and returns the input itself when nothing changes; `parse` coerces, then checks. Validators compiled without the option have neither; the README lists every rule.
|
||||||
|
- `Static` also types TypeBox-built schemas, from TypeBox 1.x's type shapes and without importing TypeBox: String, Number, Integer, Boolean, Null, Literal, Array, Object (with `Type.Optional` and `Type.Readonly` properties), Union, Record, Unsafe, Unknown and Any give TypeBox's static type; other TypeBox kinds give `unknown`.
|
||||||
- `unresolvedRefs: "reject"` compiles a `$ref` that names nothing as `false` (the error is `unresolved reference <ref>`) instead of throwing, for schemas whose references may dangle.
|
- `unresolvedRefs: "reject"` compiles a `$ref` that names nothing as `false` (the error is `unresolved reference <ref>`) instead of throwing, for schemas whose references may dangle.
|
||||||
- `formats` also checks `url` and `json-pointer-uri-fragment`, as TypeBox and ajv-formats do.
|
- `formats` also checks `url` and `json-pointer-uri-fragment`, as TypeBox and ajv-formats do.
|
||||||
- `npm run bench` compares conformance, speed (with and without eval), setup cost, memory and bundle size with ata-validator (generated code and its interpreter), Ajv, Zod (native schemas, and `z.fromJSONSchema` for the test suite), TypeBox, @cfworker/json-schema and omptype.
|
- `npm run bench` compares conformance, speed (with and without eval), setup cost, memory and bundle size with ata-validator (generated code and its interpreter), Ajv, Zod (native schemas, and `z.fromJSONSchema` for the test suite), TypeBox, @cfworker/json-schema and omptype.
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,12 @@ compile(Block).errors({ kind: "video" });
|
||||||
// [{ path: "/kind", keyword: "anyOf", message: 'expected one of ["text","image"]' }]
|
// [{ path: "/kind", keyword: "anyOf", message: 'expected one of ["text","image"]' }]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`Static` also types schemas built with TypeBox 1.x, read from TypeBox's type shapes (pi-schema does not import
|
||||||
|
TypeBox): `String`, `Number`, `Integer`, `Boolean`, `Null`, `Literal`, `Array`, `Object` (with `Type.Optional` and
|
||||||
|
`Type.Readonly` properties), `Union`, `Record`, `Unsafe`, `Unknown` and `Any` give TypeBox's own static type, so
|
||||||
|
`Static<typeof Type.Object({ path: Type.String() })>` is `{ path: string }`. Other TypeBox kinds (`Enum`, `Tuple`,
|
||||||
|
`Intersect`, `Cyclic`, ...) give `unknown` where they stand. Other JSON Schema objects give `unknown`.
|
||||||
|
|
||||||
### Options
|
### Options
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,27 @@
|
||||||
// they accept, so `Static<typeof schema>` is the TypeScript type a valid value has. The objects are ordinary data:
|
// they accept, so `Static<typeof schema>` is the TypeScript type a valid value has. The objects are ordinary data:
|
||||||
// they serialize with JSON.stringify and compile with compile() like any other schema.
|
// they serialize with JSON.stringify and compile with compile() like any other schema.
|
||||||
|
|
||||||
|
import type { TypeBoxStatic } from "./typebox-static.ts";
|
||||||
|
|
||||||
declare const STATIC: unique symbol;
|
declare const STATIC: unique symbol;
|
||||||
declare const OPTIONAL_TYPE: unique symbol;
|
declare const OPTIONAL_TYPE: unique symbol;
|
||||||
|
|
||||||
/** A JSON Schema object whose valid values have type `T` (a type-level brand; nothing extra exists at run time). */
|
/** A JSON Schema object whose valid values have type `T` (a type-level brand; nothing extra exists at run time). */
|
||||||
export type Schema<T = unknown> = { readonly [keyword: string]: unknown } & { readonly [STATIC]?: T };
|
export type Schema<T = unknown> = { readonly [keyword: string]: unknown } & { readonly [STATIC]?: T };
|
||||||
|
|
||||||
/** The type of the values `S` accepts; `unknown` for schemas not built with these functions. */
|
// TypeBox is tested first and in its own branch: a builder brand's fallback through TypeBoxStatic would leave
|
||||||
export type Static<S> = S extends { readonly [STATIC]?: infer T } ? (unknown extends T ? unknown : T) : unknown;
|
// `Static<S>` of a generic `S` (constrained to builder schemas) unresolvable to its constraint's types.
|
||||||
|
/**
|
||||||
|
* The type of the values `S` accepts: the type a builder recorded; for a TypeBox schema (`'~kind'` or `'~unsafe'`),
|
||||||
|
* TypeBox's static type for the common kinds (see `TypeBoxStatic`); else `unknown`.
|
||||||
|
*/
|
||||||
|
export type Static<S> = S extends { readonly "~kind": unknown } | { readonly "~unsafe": unknown }
|
||||||
|
? TypeBoxStatic<S>
|
||||||
|
: S extends { readonly [STATIC]?: infer T }
|
||||||
|
? unknown extends T
|
||||||
|
? unknown
|
||||||
|
: T
|
||||||
|
: unknown;
|
||||||
|
|
||||||
/** Annotations every builder accepts: they describe the schema and never change what it accepts. */
|
/** Annotations every builder accepts: they describe the schema and never change what it accepts. */
|
||||||
export interface Annotations<T = unknown> {
|
export interface Annotations<T = unknown> {
|
||||||
|
|
|
||||||
76
packages/schema/src/typebox-static.ts
Normal file
76
packages/schema/src/typebox-static.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
// The static types of TypeBox-built schemas, read from the type shapes TypeBox 1.x declares (`'~kind'`, `'~optional'`,
|
||||||
|
// `'~readonly'`, `'~unsafe'`) without importing TypeBox, so `Static` also types tool arguments of TypeBox schemas.
|
||||||
|
// Only the common kinds are mapped; every other kind (Enum, Tuple, Intersect, Cyclic, Ref, ...) is `unknown`.
|
||||||
|
|
||||||
|
import type { Static } from "./builders.ts";
|
||||||
|
|
||||||
|
/** The value type of a TypeBox schema type by its `'~kind'`, or its `Type.Unsafe` type; `unknown` for other kinds. */
|
||||||
|
export type TypeBoxStatic<S> = S extends { readonly "~kind": infer K }
|
||||||
|
? KindStatic<S, K>
|
||||||
|
: S extends { readonly "~unsafe": infer T }
|
||||||
|
? T
|
||||||
|
: unknown;
|
||||||
|
|
||||||
|
interface Primitives {
|
||||||
|
String: string;
|
||||||
|
Number: number;
|
||||||
|
Integer: number;
|
||||||
|
Boolean: boolean;
|
||||||
|
Null: null;
|
||||||
|
Unknown: unknown;
|
||||||
|
Any: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
type KindStatic<S, K> = K extends keyof Primitives
|
||||||
|
? Primitives[K]
|
||||||
|
: K extends "Literal"
|
||||||
|
? S extends { readonly const: infer V }
|
||||||
|
? V
|
||||||
|
: unknown
|
||||||
|
: K extends "Array"
|
||||||
|
? S extends { readonly items: infer I }
|
||||||
|
? Static<I>[]
|
||||||
|
: unknown
|
||||||
|
: K extends "Object"
|
||||||
|
? S extends { readonly properties: infer P }
|
||||||
|
? ObjectStatic<P>
|
||||||
|
: unknown
|
||||||
|
: K extends "Union"
|
||||||
|
? S extends { readonly anyOf: readonly (infer B)[] }
|
||||||
|
? Static<B>
|
||||||
|
: unknown
|
||||||
|
: K extends "Record"
|
||||||
|
? S extends { readonly patternProperties: infer P }
|
||||||
|
? Record<RecordKey<keyof P>, Static<P[keyof P]>>
|
||||||
|
: unknown
|
||||||
|
: unknown;
|
||||||
|
|
||||||
|
type Optional = { readonly "~optional": true };
|
||||||
|
|
||||||
|
type Readonly = { readonly "~readonly": true };
|
||||||
|
|
||||||
|
// As TypeBox: no properties is `object`; `'~optional'` properties may be omitted, `'~readonly'` ones are readonly.
|
||||||
|
type ObjectStatic<P> = keyof P extends never
|
||||||
|
? object
|
||||||
|
: Simplify<
|
||||||
|
{ -readonly [K in keyof P as P[K] extends Optional | Readonly ? never : K]: Static<P[K]> } & {
|
||||||
|
-readonly [K in keyof P as P[K] extends Optional ? (P[K] extends Readonly ? never : K) : never]?: Static<
|
||||||
|
P[K]
|
||||||
|
>;
|
||||||
|
} & {
|
||||||
|
readonly [K in keyof P as P[K] extends Readonly ? (P[K] extends Optional ? never : K) : never]: Static<
|
||||||
|
P[K]
|
||||||
|
>;
|
||||||
|
} & {
|
||||||
|
readonly [K in keyof P as P[K] extends Optional ? (P[K] extends Readonly ? K : never) : never]?: Static<
|
||||||
|
P[K]
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
type Simplify<T> = { [K in keyof T]: T[K] } & {};
|
||||||
|
|
||||||
|
// TypeBox's key patterns for integer and number keys; every other pattern types its keys as strings.
|
||||||
|
type RecordKey<Pattern> = Pattern extends "^-?(?:0|[1-9][0-9]*)$" | "^-?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?$"
|
||||||
|
? number
|
||||||
|
: string;
|
||||||
72
packages/schema/test/typebox-static.test.ts
Normal file
72
packages/schema/test/typebox-static.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
// Static for TypeBox-built schemas: the common kinds give TypeBox's own static type, without pi-schema importing
|
||||||
|
// TypeBox; other kinds give `unknown`. Type-level tests (they pass when this file type-checks), against TypeBox 1.3.27.
|
||||||
|
|
||||||
|
import { Type, type Static as TypeBoxOwn } from "typebox";
|
||||||
|
import { describe, expectTypeOf, it } from "vitest";
|
||||||
|
import { compile, type Static } from "../src/index.ts";
|
||||||
|
|
||||||
|
describe("Static of TypeBox schemas", () => {
|
||||||
|
it("maps the primitive kinds, literals and Unsafe", () => {
|
||||||
|
expectTypeOf<Static<ReturnType<typeof Type.String>>>().toEqualTypeOf<string>();
|
||||||
|
expectTypeOf<Static<ReturnType<typeof Type.Number>>>().toEqualTypeOf<number>();
|
||||||
|
expectTypeOf<Static<ReturnType<typeof Type.Integer>>>().toEqualTypeOf<number>();
|
||||||
|
expectTypeOf<Static<ReturnType<typeof Type.Boolean>>>().toEqualTypeOf<boolean>();
|
||||||
|
expectTypeOf<Static<ReturnType<typeof Type.Null>>>().toEqualTypeOf<null>();
|
||||||
|
expectTypeOf<Static<ReturnType<typeof Type.Unknown>>>().toEqualTypeOf<unknown>();
|
||||||
|
const any = Type.Any();
|
||||||
|
expectTypeOf<Static<typeof any>>().toBeAny();
|
||||||
|
const literal = Type.Literal("add");
|
||||||
|
expectTypeOf<Static<typeof literal>>().toEqualTypeOf<"add">();
|
||||||
|
const unsafe = Type.Unsafe<"a" | "b">({ type: "string", enum: ["a", "b"] });
|
||||||
|
expectTypeOf<Static<typeof unsafe>>().toEqualTypeOf<"a" | "b">();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps objects with optional and readonly properties as TypeBox does", () => {
|
||||||
|
const schema = Type.Object({
|
||||||
|
path: Type.String(),
|
||||||
|
offset: Type.Optional(Type.Integer()),
|
||||||
|
id: Type.Readonly(Type.String()),
|
||||||
|
tag: Type.Readonly(Type.Optional(Type.Literal("x"))),
|
||||||
|
});
|
||||||
|
type Expected = { path: string; offset?: number; readonly id: string; readonly tag?: "x" };
|
||||||
|
expectTypeOf<Static<typeof schema>>().toEqualTypeOf<Expected>();
|
||||||
|
expectTypeOf<Static<typeof schema>>().toEqualTypeOf<TypeBoxOwn<typeof schema>>();
|
||||||
|
const empty = Type.Object({});
|
||||||
|
expectTypeOf<Static<typeof empty>>().toEqualTypeOf<TypeBoxOwn<typeof empty>>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps arrays, unions and records, nested", () => {
|
||||||
|
const schema = Type.Object({
|
||||||
|
edits: Type.Array(Type.Object({ oldText: Type.String(), newText: Type.String() })),
|
||||||
|
mode: Type.Union([Type.Literal("text"), Type.Literal("binary"), Type.Null()]),
|
||||||
|
env: Type.Record(Type.String(), Type.Union([Type.String(), Type.Number()])),
|
||||||
|
counts: Type.Record(Type.Integer(), Type.Boolean()),
|
||||||
|
});
|
||||||
|
type Expected = {
|
||||||
|
edits: { oldText: string; newText: string }[];
|
||||||
|
mode: "text" | "binary" | null;
|
||||||
|
env: Record<string, string | number>;
|
||||||
|
counts: Record<number, boolean>;
|
||||||
|
};
|
||||||
|
expectTypeOf<Static<typeof schema>>().toEqualTypeOf<Expected>();
|
||||||
|
expectTypeOf<Static<typeof schema>>().toEqualTypeOf<TypeBoxOwn<typeof schema>>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives unknown for kinds it does not map", () => {
|
||||||
|
const enumeration = Type.Enum(["a", "b"]);
|
||||||
|
const tuple = Type.Tuple([Type.String()]);
|
||||||
|
const intersect = Type.Intersect([Type.Object({ a: Type.String() }), Type.Object({ b: Type.String() })]);
|
||||||
|
expectTypeOf<Static<typeof enumeration>>().toEqualTypeOf<unknown>();
|
||||||
|
expectTypeOf<Static<typeof tuple>>().toEqualTypeOf<unknown>();
|
||||||
|
expectTypeOf<Static<typeof intersect>>().toEqualTypeOf<unknown>();
|
||||||
|
// Inside a mapped kind, an unmapped kind is unknown only where it stands.
|
||||||
|
const object = Type.Object({ pair: Type.Tuple([Type.String()]), name: Type.String() });
|
||||||
|
expectTypeOf<Static<typeof object>>().toEqualTypeOf<{ pair: unknown; name: string }>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("types compile() and parse() for TypeBox schemas", () => {
|
||||||
|
const schema = Type.Object({ n: Type.Number(), s: Type.Optional(Type.String()) });
|
||||||
|
const result = compile(schema, { coerce: true }).parse({ n: "1" });
|
||||||
|
if (result.ok) expectTypeOf(result.value).toEqualTypeOf<{ n: number; s?: string }>();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue