diff --git a/packages/quicktype-core/src/RendererOptions/types.ts b/packages/quicktype-core/src/RendererOptions/types.ts index f52a1a7419..7bb21ae9bc 100644 --- a/packages/quicktype-core/src/RendererOptions/types.ts +++ b/packages/quicktype-core/src/RendererOptions/types.ts @@ -54,7 +54,13 @@ export type OptionValue = export type OptionKey = O extends EnumOption, infer EnumKey> ? EnumKey - : O; + : O extends Option + ? Value extends boolean + ? // `BooleanOption.getValue` also accepts the strings + // "true" and "false", which is what the CLI passes. + boolean | `${boolean}` + : Value + : never; // FIXME: Merge these and use camelCase user-facing keys (v24) export type OptionMap = { diff --git a/packages/quicktype-core/src/Run.ts b/packages/quicktype-core/src/Run.ts index b97436c9dc..261cdec62f 100644 --- a/packages/quicktype-core/src/Run.ts +++ b/packages/quicktype-core/src/Run.ts @@ -113,10 +113,11 @@ export interface NonInferenceOptions { */ outputFilename: string; /** Options for the target language's renderer */ - rendererOptions: RendererOptions; + rendererOptions: Partial>; } -export type Options = NonInferenceOptions & InferenceFlags; +export type Options = + NonInferenceOptions & InferenceFlags; const defaultOptions: NonInferenceOptions = { lang: "ts", @@ -600,15 +601,15 @@ class Run implements RunContext { * @param options Partial options. For options that are not defined, the * defaults will be used. */ -export async function quicktypeMultiFile( - options: Partial, -): Promise { +export async function quicktypeMultiFile< + Lang extends LanguageName = LanguageName, +>(options: Partial>): Promise { return await new Run(options).run(); } -export function quicktypeMultiFileSync( - options: Partial, -): MultiFileRenderResult { +export function quicktypeMultiFileSync< + Lang extends LanguageName = LanguageName, +>(options: Partial>): MultiFileRenderResult { return new Run(options).runSync(); } @@ -664,8 +665,8 @@ export function combineRenderResults( * @param options Partial options. For options that are not defined, the * defaults will be used. */ -export async function quicktype( - options: Partial, +export async function quicktype( + options: Partial>, ): Promise { const result = await quicktypeMultiFile(options); return combineRenderResults(result); diff --git a/test/languages.ts b/test/languages.ts index 5d971b30ca..b064f74c8d 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -620,7 +620,6 @@ export const CPlusPlusLanguage: Language = { ], rendererOptions: {}, quickTestRendererOptions: [ - { unions: "indirection" }, { "source-style": "multi-source" }, { "code-format": "with-struct" }, { wstring: "use-wstring" }, @@ -852,7 +851,6 @@ export const TypeScriptLanguage: Language = { { "runtime-typecheck": "false" }, { "runtime-typecheck-ignore-unknown-properties": "true" }, { "nice-property-names": "true" }, - { "declare-unions": "true" }, ["pokedex.json", { "prefer-types": "true" }], { "acronym-style": "pascal" }, { converters: "all-objects" }, @@ -910,11 +908,7 @@ export const JavaScriptPropTypesLanguage: Language = { skipSchema: [], skipMiscJSON: false, rendererOptions: { "module-system": "es6" }, - quickTestRendererOptions: [ - { "runtime-typecheck": "false" }, - { "runtime-typecheck-ignore-unknown-properties": "true" }, - { converters: "top-level" }, - ], + quickTestRendererOptions: [{ converters: "top-level" }], sourceFiles: ["src/language/JavaScriptPropTypes/index.ts"], }; @@ -940,7 +934,6 @@ export const FlowLanguage: Language = { { "runtime-typecheck": "false" }, { "runtime-typecheck-ignore-unknown-properties": "true" }, { "nice-property-names": "true" }, - { "declare-unions": "true" }, ], sourceFiles: ["src/language/Flow/index.ts"], }; @@ -1602,7 +1595,7 @@ export const TypeScriptZodLanguage: Language = { "required-non-properties.schema", ], rendererOptions: {}, - quickTestRendererOptions: [{ "array-type": "list" }], + quickTestRendererOptions: [], sourceFiles: ["src/language/TypeScriptZod/index.ts"], }; @@ -1717,7 +1710,7 @@ export const TypeScriptEffectSchemaLanguage: Language = { "required-non-properties.schema", ], rendererOptions: {}, - quickTestRendererOptions: [{ "array-type": "list" }], + quickTestRendererOptions: [], sourceFiles: ["src/language/TypeScriptEffectSchema/index.ts"], }; diff --git a/test/unit/renderer-options.test-d.ts b/test/unit/renderer-options.test-d.ts new file mode 100644 index 0000000000..f86fcde9c3 --- /dev/null +++ b/test/unit/renderer-options.test-d.ts @@ -0,0 +1,90 @@ +// Type-level tests: `rendererOptions` keys and values are validated by the +// type system against the target language given in `lang`. When `lang` is +// a string literal, `quicktype` infers it and unknown option names or +// invalid enum values are compile errors — there is no runtime validation. +// See https://github.com/glideapps/quicktype/issues/2933. +import { describe, test } from "vitest"; + +import { + InputData, + type LanguageName, + quicktype, + quicktypeMultiFile, +} from "../../packages/quicktype-core/src/index.js"; + +const inputData = new InputData(); + +describe("rendererOptions typing", () => { + test("accepts a language's own options", () => { + void quicktype({ + inputData, + lang: "csharp", + rendererOptions: { + namespace: "Acme", + framework: "SystemTextJson", + "csharp-version": "6", + }, + }); + }); + + test("boolean options accept booleans and their string forms", () => { + void quicktype({ + inputData, + lang: "typescript", + rendererOptions: { "just-types": true }, + }); + void quicktype({ + inputData, + lang: "typescript", + rendererOptions: { "just-types": "true" }, + }); + }); + + test("rejects an unknown option name", () => { + void quicktype({ + inputData, + lang: "typescript", + // @ts-expect-error unknown option name + rendererOptions: { "totally-bogus-option": "yes" }, + }); + }); + + test("rejects another language's option name", () => { + void quicktype({ + inputData, + lang: "kotlin", + // @ts-expect-error `just-types` is a C#/TypeScript spelling; Kotlin has no such option + rendererOptions: { "just-types": "true" }, + }); + }); + + test("rejects an invalid enum option value", () => { + void quicktype({ + inputData, + lang: "csharp", + // @ts-expect-error GSON is not a C# serialization framework + rendererOptions: { framework: "GSON" }, + }); + }); + + test("quicktypeMultiFile enforces the same typing", () => { + void quicktypeMultiFile({ + inputData, + lang: "csharp", + // @ts-expect-error unknown option name + rendererOptions: { "totally-bogus-option": "yes" }, + }); + }); + + test("stays permissive when the language is not a literal", () => { + // Callers that don't pin `lang` to a literal — or omit it — keep + // the old, unchecked behavior. + const lang: LanguageName = "csharp" as LanguageName; + void quicktype({ + inputData, + lang, + rendererOptions: { "just-types": "true" }, + }); + void quicktype({ inputData, rendererOptions: {} }); + }); +}); diff --git a/test/unit/tsconfig.json b/test/unit/tsconfig.json new file mode 100644 index 0000000000..95bbf39f58 --- /dev/null +++ b/test/unit/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@tsconfig/node20/tsconfig.json", + "compilerOptions": { + "lib": ["ES2022"], + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true + }, + "include": ["./**/*.test-d.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts index 9865b9fdd4..ee9c4672f4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,5 +5,10 @@ export default defineConfig({ environment: "node", include: ["test/unit/**/*.test.ts"], testTimeout: 30_000, + typecheck: { + enabled: true, + include: ["test/unit/**/*.test-d.ts"], + tsconfig: "./test/unit/tsconfig.json", + }, }, });