Skip to content

Commit c2ec85c

Browse files
maxholmanclaude
andcommitted
feat!: rename schema modes to input/wire, fix optional handling per direction
The previous exact/coerced split conflated two orthogonal concerns: optional handling and wire coercion. The "coerced" variant — used by the hono middleware and rest-client response validation — was permissive about \`undefined\` even though JSON-parsed wire data can never carry one. The "exact" variant — intended for strict in-memory use — rejected \`undefined\` even though TS callers legitimately destructure-with-default into it. Realigned along direction of data flow: input — TS-side outgoing. Uses v.optional, no wire coercion. Tolerates \`{ foo: undefined }\` since JSON.stringify strips those keys. For pre-flight client-side validation of request data. wire — JSON-parsed incoming. Uses v.exactOptional (present-or-absent, never undefined). Includes bigint/number coercion for HTTP wire formats. For server middleware and response parsing. Generated naming flips accordingly: exactPetSchema → inputPetSchema (was: exactOptional + no coercion; now: optional + no coercion) petSchema → petSchema (was: optional + coercion; now: exactOptional + coercion) CLI flag: --exact-only → --input-only. CodegenOptions: exactOnly → inputOnly. The hono middleware and validated commands continue to use the wire variant — now with correct exactOptional semantics so handlers receive clean \`{ field?: T }\` types with no undefined leakage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9162cd6 commit c2ec85c

5 files changed

Lines changed: 149 additions & 128 deletions

File tree

__tests__/__snapshots__/nullables.test.ts.snap

Lines changed: 31 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ export type NullConst = null;
1111
exports[`const values 2`] = `
1212
"import * as v from "valibot";
1313
14-
export const exactStringConstSchema = v.literal("hello");
15-
export const stringConstSchema = exactStringConstSchema;
16-
export const exactNumberConstSchema = v.literal(42);
17-
export const numberConstSchema = exactNumberConstSchema;
18-
export const exactBooleanConstSchema = v.literal(true);
19-
export const booleanConstSchema = exactBooleanConstSchema;
20-
export const exactNullConstSchema = v.null();
21-
export const nullConstSchema = exactNullConstSchema;
14+
export const inputStringConstSchema = v.literal("hello");
15+
export const stringConstSchema = inputStringConstSchema;
16+
export const inputNumberConstSchema = v.literal(42);
17+
export const numberConstSchema = inputNumberConstSchema;
18+
export const inputBooleanConstSchema = v.literal(true);
19+
export const booleanConstSchema = inputBooleanConstSchema;
20+
export const inputNullConstSchema = v.null();
21+
export const nullConstSchema = inputNullConstSchema;
2222
"
2323
`;
2424

@@ -77,25 +77,25 @@ export class UploadDataCommand extends commands.UploadDataCommand {
7777
exports[`header parameters 4`] = `
7878
"import * as v from "valibot";
7979
80-
export const exactUploadStatusSchema = v.picklist(["pending", "complete"]);
81-
export const uploadStatusSchema = exactUploadStatusSchema;
82-
export const exactUploadDataCommandResponseSchema = exactUploadStatusSchema;
80+
export const inputUploadStatusSchema = v.picklist(["pending", "complete"]);
81+
export const uploadStatusSchema = inputUploadStatusSchema;
82+
export const inputUploadDataCommandResponseSchema = inputUploadStatusSchema;
8383
export const uploadDataCommandResponseSchema = uploadStatusSchema;
84-
export const exactUploadDataCommandParamsSchema = v.strictObject({
84+
export const inputUploadDataCommandParamsSchema = v.strictObject({
8585
"uploadId": v.string()
8686
});
8787
export const uploadDataCommandParamsSchema = v.strictObject({
8888
"uploadId": v.pipe(v.string(), v.trim())
8989
});
90-
export const exactUploadDataCommandHeaderSchema = v.object({
90+
export const inputUploadDataCommandHeaderSchema = v.object({
9191
"content-type": v.picklist(["application/json", "text/csv", "application/xml"]),
9292
"content-length": v.bigint(),
93-
"x-idempotency-key": v.exactOptional(v.pipe(v.string(), v.uuid()))
93+
"x-idempotency-key": v.optional(v.pipe(v.string(), v.uuid()))
9494
});
9595
export const uploadDataCommandHeaderSchema = v.object({
9696
"content-type": v.picklist(["application/json", "text/csv", "application/xml"]),
9797
"content-length": v.union([v.pipe(v.string(), v.decimal(), v.toBigint(), v.bigint()), v.pipe(v.number(), v.integer(), v.toBigint(), v.bigint()), v.bigint()]),
98-
"x-idempotency-key": v.optional(v.pipe(v.string(), v.uuid()))
98+
"x-idempotency-key": v.exactOptional(v.pipe(v.string(), v.uuid()))
9999
});
100100
"
101101
`;
@@ -131,7 +131,7 @@ exports[`nullables 1`] = `
131131
exports[`oneOf with type null generates v.null() 1`] = `
132132
"import * as v from "valibot";
133133
134-
export const exactNullableImageSchema = v.union([v.string(), v.null()]);
134+
export const inputNullableImageSchema = v.union([v.string(), v.null()]);
135135
export const nullableImageSchema = v.union([v.pipe(v.string(), v.trim()), v.null()]);
136136
"
137137
`;
@@ -153,21 +153,21 @@ export type ListFilesCommandInput = ListFilesCommandQuery;
153153
exports[`query and header integer params coerce strings to numbers 2`] = `
154154
"import * as v from "valibot";
155155
156-
export const exactDummySchema = v.string();
156+
export const inputDummySchema = v.string();
157157
export const dummySchema = v.pipe(v.string(), v.trim());
158-
export const exactExpireTimeSchema = v.pipe(v.bigint(), v.minValue(BigInt(0)));
158+
export const inputExpireTimeSchema = v.pipe(v.bigint(), v.minValue(BigInt(0)));
159159
export const expireTimeSchema = v.union([v.pipe(v.string(), v.decimal(), v.toBigint(), v.pipe(v.bigint(), v.minValue(BigInt(0)))), v.pipe(v.number(), v.integer(), v.toBigint(), v.pipe(v.bigint(), v.minValue(BigInt(0)))), v.pipe(v.bigint(), v.minValue(BigInt(0)))]);
160-
export const exactListFilesCommandResponseSchema = exactDummySchema;
160+
export const inputListFilesCommandResponseSchema = inputDummySchema;
161161
export const listFilesCommandResponseSchema = dummySchema;
162-
export const exactListFilesCommandQuerySchema = v.strictObject({
162+
export const inputListFilesCommandQuerySchema = v.strictObject({
163163
"exp": v.pipe(v.bigint(), v.minValue(BigInt(0))),
164-
"limit": v.exactOptional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100)))
164+
"limit": v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100)))
165165
});
166166
export const listFilesCommandQuerySchema = v.strictObject({
167167
"exp": v.union([v.pipe(v.string(), v.decimal(), v.toBigint(), v.pipe(v.bigint(), v.minValue(BigInt(0)))), v.pipe(v.number(), v.integer(), v.toBigint(), v.pipe(v.bigint(), v.minValue(BigInt(0)))), v.pipe(v.bigint(), v.minValue(BigInt(0)))]),
168-
"limit": v.optional(v.union([v.pipe(v.string(), v.decimal(), v.toNumber(), v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100))), v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100))]))
168+
"limit": v.exactOptional(v.union([v.pipe(v.string(), v.decimal(), v.toNumber(), v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100))), v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100))]))
169169
});
170-
export const exactListFilesCommandHeaderSchema = v.object({
170+
export const inputListFilesCommandHeaderSchema = v.object({
171171
"x-rate-limit": v.pipe(v.number(), v.integer(), v.minValue(0))
172172
});
173173
export const listFilesCommandHeaderSchema = v.object({
@@ -187,14 +187,14 @@ export type MultiType = string | number;
187187
exports[`top-level type array with null 2`] = `
188188
"import * as v from "valibot";
189189
190-
export const exactNullableStringSchema = v.nullable(v.string());
191-
export const nullableStringSchema = exactNullableStringSchema;
192-
export const exactNullableStringEnumSchema = v.nullable(v.picklist(["active", "inactive"]));
193-
export const nullableStringEnumSchema = exactNullableStringEnumSchema;
194-
export const exactNullableIntegerSchema = v.nullable(v.pipe(v.number(), v.integer()));
195-
export const nullableIntegerSchema = exactNullableIntegerSchema;
196-
export const exactMultiTypeSchema = v.union([v.string(), v.number()]);
197-
export const multiTypeSchema = exactMultiTypeSchema;
190+
export const inputNullableStringSchema = v.nullable(v.string());
191+
export const nullableStringSchema = inputNullableStringSchema;
192+
export const inputNullableStringEnumSchema = v.nullable(v.picklist(["active", "inactive"]));
193+
export const nullableStringEnumSchema = inputNullableStringEnumSchema;
194+
export const inputNullableIntegerSchema = v.nullable(v.pipe(v.number(), v.integer()));
195+
export const nullableIntegerSchema = inputNullableIntegerSchema;
196+
export const inputMultiTypeSchema = v.union([v.string(), v.number()]);
197+
export const multiTypeSchema = inputMultiTypeSchema;
198198
"
199199
`;
200200

__tests__/nullables.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ test("header parameters", async () => {
279279
expect(result.honoValibotFile.getText()).toMatchSnapshot();
280280
});
281281

282-
test("exact-only mode omits coerced schemas", async () => {
282+
test("input-only mode omits wire schemas", async () => {
283283
const result = await processOpenApiDocument(
284284
"/tmp/like-you-know-whatever",
285285
{
@@ -325,13 +325,13 @@ test("exact-only mode omits coerced schemas", async () => {
325325
},
326326
},
327327
[],
328-
{ exactOnly: true },
328+
{ inputOnly: true },
329329
);
330330

331331
const valibotText = result.valibotFile.getText();
332332

333-
expect(valibotText).toContain("exactNameSchema");
334-
expect(valibotText).toContain("exactAmountSchema");
333+
expect(valibotText).toContain("inputNameSchema");
334+
expect(valibotText).toContain("inputAmountSchema");
335335
expect(valibotText).not.toMatch(/export const nameSchema\b/);
336336
expect(valibotText).not.toMatch(/export const amountSchema\b/);
337337
expect(valibotText).not.toContain("v.trim()");

bin/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ const cliArgs = await yargs(hideBin(process.argv))
2626
return Array.isArray(arg) ? arg : [arg];
2727
},
2828
})
29-
.option("exact-only", {
29+
.option("input-only", {
3030
type: "boolean",
31-
description: "Only emit exact schemas, skip coerced variants",
31+
description:
32+
"Only emit input schemas (TS-side, v.optional, no wire coercion); skip wire variants",
3233
default: false,
3334
});
3435
})
@@ -38,5 +39,5 @@ await build(
3839
join(process.cwd(), String(cliArgs.i)),
3940
join(process.cwd(), String(cliArgs.o)),
4041
cliArgs.t as Array<string>,
41-
{ exactOnly: Boolean(cliArgs.exactOnly) },
42+
{ inputOnly: Boolean(cliArgs.inputOnly) },
4243
);

lib/process-document.ts

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@ import {
3939
} from "./valibot.ts";
4040

4141
export interface CodegenOptions {
42-
exactOnly?: boolean;
42+
/**
43+
* Emit only `input*` variants (TS-side schemas: `v.optional`, no wire
44+
* coercion). Skips the `*Schema` (wire) variants used by hono middleware
45+
* and response parsing. For non-HTTP / in-memory-only consumers.
46+
*/
47+
inputOnly?: boolean;
4348
}
4449

4550
interface OperationMiddlewareInfo {
@@ -145,7 +150,7 @@ export async function processOpenApiDocument(
145150
const valibotFile = createValibotFile(project, outputDir);
146151

147152
// Track registered validators by their $ref path
148-
const validators = new Map<string, { exact: string; coerced: string }>();
153+
const validators = new Map<string, { input: string; wire: string }>();
149154

150155
// Track all operations for middleware generation
151156
const allOperations: OperationMiddlewareInfo[] = [];
@@ -284,7 +289,7 @@ export async function processOpenApiDocument(
284289
valibotFile,
285290
schemaName,
286291
schemaObject,
287-
options?.exactOnly,
292+
options?.inputOnly,
288293
);
289294

290295
// Add enum values to enums file
@@ -832,7 +837,7 @@ export async function processOpenApiDocument(
832837
query: queryParameters,
833838
header: headerParameters,
834839
},
835-
options?.exactOnly,
840+
options?.inputOnly,
836841
);
837842

838843
// Track operation for middleware generation (use coerced schemas)
@@ -841,9 +846,9 @@ export async function processOpenApiDocument(
841846
);
842847
allOperations.push({
843848
exportName: middlewareExportName,
844-
schemas: options?.exactOnly
845-
? operationSchemas.exact
846-
: operationSchemas.coerced,
849+
schemas: options?.inputOnly
850+
? operationSchemas.input
851+
: operationSchemas.wire,
847852
});
848853

849854
// CommandInput
@@ -993,14 +998,16 @@ export async function processOpenApiDocument(
993998
// schemas aren't read by rest-client anyway (hono-valibot.ts imports
994999
// directly from valibot.ts for server middleware), and the response
9951000
// schema lives on the validated subclass.
996-
const coercedSchemas = options?.exactOnly
997-
? operationSchemas.exact
998-
: operationSchemas.coerced;
1001+
// Wire variant is what rest-client + hono consume; falls back to
1002+
// the input variant under --input-only.
1003+
const wireSchemas = options?.inputOnly
1004+
? operationSchemas.input
1005+
: operationSchemas.wire;
9991006

1000-
if (coercedSchemas.response) {
1007+
if (wireSchemas.response) {
10011008
validatedSubclasses.push({
10021009
commandName,
1003-
responseSchema: coercedSchemas.response,
1010+
responseSchema: wireSchemas.response,
10041011
});
10051012
} else {
10061013
validatedReExports.push(commandName);

0 commit comments

Comments
 (0)