From c373b0f849c8769eed1d0f56e89a9fdb70a11022 Mon Sep 17 00:00:00 2001 From: Benjamin Staneck Date: Wed, 2 Sep 2026 12:15:04 +0200 Subject: [PATCH] fix(schema): render finite numbers as Schema.Finite and reference generated aliases directly The JSON Schema importer already attaches finite and integer checks to numbers, so SchemaRepresentation.toCodeDocument now renders those nodes from Schema.Finite and drops a finite check that only repeats its defaults. The OpenAPI generator references the generated type aliases instead of typeof X.Type in client signatures. Closes #7646 --- .changeset/direct-lanterns.md | 5 +++ .changeset/gentle-orbits.md | 5 +++ .../src/internal/schema/toCodeDocument.ts | 27 ++++++++++++- .../representation/toCodeDocument.test.ts | 38 ++++++++++++++++++- .../src/OpenApiTransformer.ts | 8 ++-- .../test/OpenApiGenerator.test.ts | 10 ++--- 6 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 .changeset/direct-lanterns.md create mode 100644 .changeset/gentle-orbits.md diff --git a/.changeset/direct-lanterns.md b/.changeset/direct-lanterns.md new file mode 100644 index 00000000000..dc973bcb8db --- /dev/null +++ b/.changeset/direct-lanterns.md @@ -0,0 +1,5 @@ +--- +"@effect/openapi-generator": patch +--- + +Reference generated type aliases directly instead of `typeof X.Type` in client signatures. diff --git a/.changeset/gentle-orbits.md b/.changeset/gentle-orbits.md new file mode 100644 index 00000000000..9b49df90e76 --- /dev/null +++ b/.changeset/gentle-orbits.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Render JSON-derived finite numbers as `Schema.Finite` in `SchemaRepresentation.toCodeDocument`. diff --git a/packages/effect/src/internal/schema/toCodeDocument.ts b/packages/effect/src/internal/schema/toCodeDocument.ts index 0e6f1804f10..4f48e2b1789 100644 --- a/packages/effect/src/internal/schema/toCodeDocument.ts +++ b/packages/effect/src/internal/schema/toCodeDocument.ts @@ -1,6 +1,7 @@ import * as Arr from "../../Array.ts" import { format, formatPropertyKey } from "../../Formatter.ts" import type * as Schema from "../../Schema.ts" +import * as SchemaAST from "../../SchemaAST.ts" import type * as SchemaRepresentation from "../../SchemaRepresentation.ts" import { errorWithPath } from "../errors.ts" import * as InternalRecord from "../record.ts" @@ -11,6 +12,14 @@ type CheckRepresentationAnnotation = SchemaRepresentation.CheckRepresentationAnn SchemaRepresentation.Representation > +function hasFiniteNumberCheck(checks: ReadonlyArray): boolean { + return checks.some((check) => + check._tag === "Filter" + ? check.representation?.id === "effect/schema/isFinite" || check.representation?.id === "effect/schema/isInt" + : hasFiniteNumberCheck(check.checks) + ) +} + /** @internal */ export function makeCode(runtime: string, Type: string): SchemaRepresentation.Code { return { runtime, Type } @@ -330,6 +339,10 @@ export function toCodeDocument( return rendered === undefined ? "" : `.${method}(${rendered})` } + function defaultFiniteAnnotations(): string { + return runtimeAnnotate(SchemaAST.isFinite().annotations) + } + function compileCheck( check: SchemaRepresentation.Check, path: Path @@ -365,6 +378,18 @@ export function toCodeDocument( for (let index = 0; index < representation.checks.length; index++) { const check = representation.checks[index] const brands = checkBrands(check) + // `Schema.Finite` already carries this check, so an unannotated or + // default-annotated `isFinite` filter adds nothing to the rendered code. + if ( + base.runtime === "Schema.Finite" && + check._tag === "Filter" && + check.representation?.id === "effect/schema/isFinite" && + !check.aborted && + brands.length === 0 + ) { + const rendered = runtimeAnnotate(check.annotations) + if (rendered === "" || rendered === defaultFiniteAnnotations()) continue + } runtime += `.check(${compileCheck(check, [...path, "checks", index])})${runtimeBrands(brands)}` if (includeTypeBrands) Type += typeBrands(brands) } @@ -434,7 +459,7 @@ export function toCodeDocument( case "String": return makeCode("Schema.String", "string") case "Number": - return makeCode("Schema.Number", "number") + return makeCode(hasFiniteNumberCheck(representation.checks) ? "Schema.Finite" : "Schema.Number", "number") case "Boolean": return makeCode("Schema.Boolean", "boolean") case "BigInt": diff --git a/packages/effect/test/schema/representation/toCodeDocument.test.ts b/packages/effect/test/schema/representation/toCodeDocument.test.ts index 206ae1c9e5c..42f6da5c7ae 100644 --- a/packages/effect/test/schema/representation/toCodeDocument.test.ts +++ b/packages/effect/test/schema/representation/toCodeDocument.test.ts @@ -452,6 +452,42 @@ describe("toCodeDocument", () => { } ) }) + + it("Number & unannotated isFinite", () => { + assertSchema( + { schema: Schema.Number.check(Schema.isFinite({ expected: undefined })) }, + { + codes: makeCode("Schema.Finite", "number") + } + ) + }) + + it("Number & default isFinite", () => { + assertSchema( + { schema: Schema.Number.check(Schema.isFinite()) }, + { + codes: makeCode("Schema.Finite", "number") + } + ) + }) + + it("Number & annotated isFinite", () => { + assertSchema( + { schema: Schema.Number.check(Schema.isFinite({ expected: undefined, description: "finite" })) }, + { + codes: makeCode(`Schema.Finite.check(Schema.isFinite().annotate({ "description": "finite" }))`, "number") + } + ) + }) + + it("Number & isInt", () => { + assertSchema( + { schema: Schema.Number.check(Schema.isInt({ expected: undefined })) }, + { + codes: makeCode("Schema.Finite.check(Schema.isInt())", "number") + } + ) + }) }) it("Boolean", () => { @@ -1850,7 +1886,7 @@ describe("toCodeDocument", () => { { $ref: "A", code: makeCode( - `Schema.Struct({ "b": Schema.Number.check(Schema.isFinite()), "a": Schema.String }).annotate({ "identifier": "A" })`, + `Schema.Struct({ "b": Schema.Finite, "a": Schema.String }).annotate({ "identifier": "A" })`, `{ readonly "b": number, readonly "a": string }` ) } diff --git a/packages/tools/openapi-generator/src/OpenApiTransformer.ts b/packages/tools/openapi-generator/src/OpenApiTransformer.ts index 6a3c9bcc114..6196b03a162 100644 --- a/packages/tools/openapi-generator/src/OpenApiTransformer.ts +++ b/packages/tools/openapi-generator/src/OpenApiTransformer.ts @@ -149,7 +149,7 @@ ${clientErrorSource(name)}` args.push(`options: { ${options.join("; ")} } | undefined`) } - const successTypes = new Set(Array.from(responses.successSchemas.values(), (schema) => `typeof ${schema}.Type`)) + const successTypes = new Set(responses.successSchemas.values()) if (responses.binarySuccessStatuses.size > 0) { successTypes.add("Uint8Array") } @@ -161,7 +161,7 @@ ${clientErrorSource(name)}` if (responses.errorSchemas.size > 0) { Utils.spreadElementsInto( Array.from(responses.errorSchemas.values()).map( - (schema) => `${name}Error<"${schema}", typeof ${schema}.Type>` + (schema) => `${name}Error<"${schema}", ${schema}>` ), errors ) @@ -206,8 +206,8 @@ ${clientErrorSource(name)}` const methodKey = `readonly "${operation.id}Sse"` const parameters = args.join(", ") const value = responses.sseSchemaMode === "event" - ? `typeof ${responses.sseSchema}.Type` - : `{ readonly event: string; readonly id: string | undefined; readonly data: typeof ${responses.sseSchema}.Type }` + ? responses.sseSchema + : `{ readonly event: string; readonly id: string | undefined; readonly data: ${responses.sseSchema} }` const returnType = `Stream.Stream<${value}, HttpClientError.HttpClientError | SchemaError | Sse.Retry | Sse.SseError, typeof ${responses.sseSchema}.DecodingServices>` return `${jsdoc}${methodKey}: (${parameters}) => ${returnType}` diff --git a/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts b/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts index 1e581af5e4c..dbf5e93d175 100644 --- a/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts +++ b/packages/tools/openapi-generator/test/OpenApiGenerator.test.ts @@ -763,7 +763,7 @@ export const make = ( export interface TestClient { readonly httpClient: HttpClient.HttpClient - readonly "getUser": (id: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError> + readonly "getUser": (id: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError> } export interface TestClientError { @@ -895,7 +895,7 @@ export const TestClientError = ( }, [ `import * as Sse from "effect/unstable/encoding/Sse"`, - `readonly "streamEventsSse": () => Stream.Stream<{ readonly event: string; readonly id: string | undefined; readonly data: typeof StreamEvents200Sse.Type }, HttpClientError.HttpClientError | SchemaError | Sse.Retry | Sse.SseError, typeof StreamEvents200Sse.DecodingServices>`, + `readonly "streamEventsSse": () => Stream.Stream<{ readonly event: string; readonly id: string | undefined; readonly data: StreamEvents200Sse }, HttpClientError.HttpClientError | SchemaError | Sse.Retry | Sse.SseError, typeof StreamEvents200Sse.DecodingServices>`, `"streamEventsSse": () => HttpClientRequest.get(\`/events\`).pipe(`, `sseRequest(StreamEvents200Sse)`, `schema: Schema.ConstraintDecoder` @@ -957,7 +957,7 @@ export const TestClientError = ( [ `"id": Schema.optionalKey(Schema.String), "event": Schema.Literal("message"), "data": Schema.String`, `"id": Schema.optionalKey(Schema.String), "event": Schema.Literal("effect/httpapi/stream/failure"), "data": Schema.String`, - `readonly "streamEventsSse": () => Stream.Stream`, + `readonly "streamEventsSse": () => Stream.Stream`, `sseEventRequest(StreamEvents200Sse)`, `Stream.pipeThroughChannel(Sse.decodeSchema(schema))` ] @@ -1054,7 +1054,7 @@ export const TestClientError = ( `TestClientError<"500", undefined>` ], [ `"200": decodeSuccess(DownloadMixedContent200)`, - `typeof DownloadMixedContent200.Type | Uint8Array` + `DownloadMixedContent200 | Uint8Array` ])) it.effect("routes mixed and non-2xx bodiless success responses", () => @@ -1062,7 +1062,7 @@ export const TestClientError = ( `"200": decodeSuccess(MixedSuccess200)`, `"204": () => Effect.void`, `"304": () => Effect.void`, - `WithOptionalResponse` + `WithOptionalResponse` ])) })