From b2abf9250df1783e9a8ae89f1df352729314995d Mon Sep 17 00:00:00 2001 From: Ivan Buryak Date: Fri, 21 Aug 2026 11:52:44 +0500 Subject: [PATCH 1/2] Fix falsy option arguments skipping validation Optional arguments were guarded with `if (value)` and rendered with `value || ""`, so falsy but invalid values such as `null` or an empty color skipped validation and were silently rendered as omitted arguments. Check for `undefined` instead, matching `gradient` and `progressive_blur`. Also validate `colorize.color` and `monochrome.color` as hex colors, and tie the `jpeg_options` progressive cross-check to `optimize_scans` being enabled rather than merely present. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G8Aa3xeVUCaxKtQJDzZhMo --- .../falsy-values-skipping-validation.md | 7 ++++++ src/options/colorize.ts | 4 +++- src/options/gravity.ts | 15 +++++++++---- src/options/jpegOptions.ts | 16 ++++++++------ src/options/monochrome.ts | 5 +++-- src/options/pngOptions.ts | 9 ++++---- src/options/resize.ts | 16 ++++++++------ src/options/size.ts | 10 +++++---- src/options/trim.ts | 4 ++-- src/options/unsharpMasking.ts | 9 ++++---- src/options/watermark.ts | 16 +++++++------- src/options/watermarkSize.ts | 8 +++---- tests/optionsBasic/colorize.test.ts | 8 +++++++ tests/optionsBasic/gravity.test.ts | 9 ++++++++ tests/optionsBasic/jpegOptions.test.ts | 15 +++++++++++++ tests/optionsBasic/monochrome.test.ts | 8 +++++++ tests/optionsBasic/pngOptions.test.ts | 15 +++++++++++++ tests/optionsBasic/resize.test.ts | 22 +++++++++++++++++++ tests/optionsBasic/size.test.ts | 13 +++++++++++ tests/optionsBasic/trim.test.ts | 8 +++++++ tests/optionsBasic/unsharpMasking.test.ts | 9 ++++++++ tests/optionsBasic/watermark.test.ts | 22 +++++++++++++++++++ tests/optionsBasic/watermarkSize.test.ts | 13 +++++++++++ 23 files changed, 214 insertions(+), 47 deletions(-) create mode 100644 .changeset/falsy-values-skipping-validation.md diff --git a/.changeset/falsy-values-skipping-validation.md b/.changeset/falsy-values-skipping-validation.md new file mode 100644 index 00000000..ee4c3591 --- /dev/null +++ b/.changeset/falsy-values-skipping-validation.md @@ -0,0 +1,7 @@ +--- +"@imgproxy/imgproxy-js-core": patch +--- + +Fix optional arguments being checked for truthiness instead of presence in `resize`, `size`, `watermark`, `watermark_size`, `trim`, `unsharp_masking`, `png_options`, `jpeg_options`, `colorize`, `monochrome` and `gravity`. Falsy but invalid values (such as `null` or an empty `color`) skipped validation and were silently rendered as omitted arguments, so `{ watermark_size: { width: null, height: 100 } }` produced `wms::100` instead of raising. Such values now raise an error, and explicitly provided `0` values are rendered instead of being dropped. `colorize.color` and `monochrome.color` are now validated as hex colors, matching `trim.color` and `gradient.color`. + +Also fix `jpeg_options` rejecting `progressive: false` when `optimize_scans` was set to `false`. The cross-check now applies only when `optimize_scans` is actually enabled. diff --git a/src/options/colorize.ts b/src/options/colorize.ts index 77c428b1..1c76a633 100644 --- a/src/options/colorize.ts +++ b/src/options/colorize.ts @@ -1,5 +1,5 @@ import type { Colorize, ColorizeOptionsPartial } from "../types/colorize"; -import { guardIsNotNum, guardIsUndef } from "../utils"; +import { guardIsNotNum, guardIsNotStr, guardIsUndef } from "../utils"; const getOpt = (options: ColorizeOptionsPartial): Colorize | undefined => options.colorize ?? options.col; @@ -22,6 +22,8 @@ const build = (options: ColorizeOptionsPartial): string => { }, }); + if (color !== undefined) guardIsNotStr(color, "colorize.color", true); + let result = `col:${opacity}`; if (color) { diff --git a/src/options/gravity.ts b/src/options/gravity.ts index b1095aa3..a023257c 100644 --- a/src/options/gravity.ts +++ b/src/options/gravity.ts @@ -59,8 +59,12 @@ const build = ( const type = gravityOpts.type; guardIsValidVal(currentAllTypes, type, "gravity.type"); - // @ts-expect-error: Let's ignore an error. - if (gravityOpts.x_offset || gravityOpts.y_offset) + if ( + // @ts-expect-error: Let's ignore an error. + gravityOpts.x_offset !== undefined || + // @ts-expect-error: Let's ignore an error. + gravityOpts.y_offset !== undefined + ) guardIsValidVal(currentBaseTypes, type, "gravity.type"); // @ts-expect-error: Let's ignore an error. if (gravityOpts.class_names && type !== "obj") @@ -68,8 +72,11 @@ const build = ( // @ts-expect-error: Let's ignore an error. if (gravityOpts.class_weights && type !== "objw") throw new Error("gravity.class_weights can be used only with type objw"); - // @ts-expect-error: Let's ignore an error. - if ((gravityOpts.x || gravityOpts.y) && type !== "fp") + if ( + // @ts-expect-error: Let's ignore an error. + (gravityOpts.x !== undefined || gravityOpts.y !== undefined) && + type !== "fp" + ) throw new Error("gravity.x and gravity.y can be used only with type fp"); if (type === "sm") { diff --git a/src/options/jpegOptions.ts b/src/options/jpegOptions.ts index 1b55facc..1637bf9b 100644 --- a/src/options/jpegOptions.ts +++ b/src/options/jpegOptions.ts @@ -19,21 +19,23 @@ const build = (options: JPEGOptionsPartial): string => { quant_table, } = jpegOptions; - if (progressive) guardIsNotBool(progressive, "jpeg_options.progressive"); - if (no_subsample) guardIsNotBool(no_subsample, "jpeg_options.no_subsample"); - if (trellis_quant) + if (progressive !== undefined) + guardIsNotBool(progressive, "jpeg_options.progressive"); + if (no_subsample !== undefined) + guardIsNotBool(no_subsample, "jpeg_options.no_subsample"); + if (trellis_quant !== undefined) guardIsNotBool(trellis_quant, "jpeg_options.trellis_quant"); - if (overshoot_deringing) + if (overshoot_deringing !== undefined) guardIsNotBool(overshoot_deringing, "jpeg_options.overshoot_deringing"); - if (optimize_scans) { + if (optimize_scans !== undefined) { guardIsNotBool(optimize_scans, "jpeg_options.optimize_scans"); - if (progressive === false) + if (optimize_scans && progressive === false) throw new Error( "jpeg_options.progressive must be true if jpeg_options.optimize_scans is true" ); } - if (quant_table) + if (quant_table !== undefined) guardIsNotNum(quant_table, "jpeg_options.quant_table", { addParam: { min: 0, max: 8 }, }); diff --git a/src/options/monochrome.ts b/src/options/monochrome.ts index ab64fa8d..e8613bf3 100644 --- a/src/options/monochrome.ts +++ b/src/options/monochrome.ts @@ -1,5 +1,5 @@ import type { Monochrome, MonochromeOptionsPartial } from "../types/monochrome"; -import { guardIsNotNum, guardIsUndef } from "../utils"; +import { guardIsNotNum, guardIsNotStr, guardIsUndef } from "../utils"; const getOpt = (options: MonochromeOptionsPartial): Monochrome | undefined => options.monochrome ?? options.mc; @@ -22,7 +22,8 @@ const build = (options: MonochromeOptionsPartial): string => { }, }); - if (color) { + if (color !== undefined) { + guardIsNotStr(color, "monochrome.color", true); return `mc:${intensity}:${color}`; } diff --git a/src/options/pngOptions.ts b/src/options/pngOptions.ts index c4269065..6805578d 100644 --- a/src/options/pngOptions.ts +++ b/src/options/pngOptions.ts @@ -12,16 +12,17 @@ const build = (options: PNGOptionsPartial): string => { guardIsUndef(pngOptions, "png_options"); const { interlaced, quantize, quantization_colors } = pngOptions; - if (interlaced) guardIsNotBool(interlaced, "png_options.interlaced"); - if (quantize) guardIsNotBool(quantize, "png_options.quantize"); - if (quantization_colors) + if (interlaced !== undefined) + guardIsNotBool(interlaced, "png_options.interlaced"); + if (quantize !== undefined) guardIsNotBool(quantize, "png_options.quantize"); + if (quantization_colors !== undefined) guardIsNotNum(quantization_colors, "png_options.quantization_colors", { addParam: { min: 2, max: 256 }, }); const inter = interlaced === undefined ? "" : interlaced; const quant = quantize === undefined ? "" : quantize; - const qc = quantization_colors || ""; + const qc = quantization_colors ?? ""; return `pngo:${inter}:${quant}:${qc}`.replace(/:+$/, ""); }; diff --git a/src/options/resize.ts b/src/options/resize.ts index fdcd070a..2df9e097 100644 --- a/src/options/resize.ts +++ b/src/options/resize.ts @@ -27,14 +27,16 @@ const build = (options: ResizeOptionsPartial): string => { guardIsUndef(resizeOpts, "resize"); const { resizing_type, width, height, enlarge } = resizeOpts; - if (resizing_type) + if (resizing_type !== undefined) guardIsValidVal(correctTypes, resizing_type, "resize.resizing_type"); - if (width) guardIsNotNum(width, "resize.width", { addParam: { min: 0 } }); - if (height) guardIsNotNum(height, "resize.height", { addParam: { min: 0 } }); - - const rt = resizing_type || ""; - const w = width || ""; - const h = height || ""; + if (width !== undefined) + guardIsNotNum(width, "resize.width", { addParam: { min: 0 } }); + if (height !== undefined) + guardIsNotNum(height, "resize.height", { addParam: { min: 0 } }); + + const rt = resizing_type ?? ""; + const w = width ?? ""; + const h = height ?? ""; const el = enlarge === undefined ? "" : normalizeBoolean(enlarge); const ex = extendOpt.test(resizeOpts) ? extendOpt.build(resizeOpts, { headless: true }) diff --git a/src/options/size.ts b/src/options/size.ts index 5f0cb16d..6a20723a 100644 --- a/src/options/size.ts +++ b/src/options/size.ts @@ -13,11 +13,13 @@ const build = (options: SizeOptionsPartial): string => { guardIsUndef(sizeOpts, "size"); const { width, height, enlarge } = sizeOpts; - if (width) guardIsNotNum(width, "size.width", { addParam: { min: 0 } }); - if (height) guardIsNotNum(height, "size.height", { addParam: { min: 0 } }); + if (width !== undefined) + guardIsNotNum(width, "size.width", { addParam: { min: 0 } }); + if (height !== undefined) + guardIsNotNum(height, "size.height", { addParam: { min: 0 } }); - const w = width || ""; - const h = height || ""; + const w = width ?? ""; + const h = height ?? ""; const el = enlarge === undefined ? "" : normalizeBoolean(enlarge); const ex = extendOpt.test(sizeOpts) ? extendOpt.build(sizeOpts, { headless: true }) diff --git a/src/options/trim.ts b/src/options/trim.ts index 670ce415..685c6dce 100644 --- a/src/options/trim.ts +++ b/src/options/trim.ts @@ -19,11 +19,11 @@ const build = (options: TrimOptionsPartial): string => { guardIsUndef(threshold, "trim.threshold"); guardIsNotNum(threshold, "trim.threshold"); - if (color) guardIsNotStr(color, "trim.color", true); + if (color !== undefined) guardIsNotStr(color, "trim.color", true); const eh = equal_hor === undefined ? "" : normalizeBoolean(equal_hor); const ev = equal_ver === undefined ? "" : normalizeBoolean(equal_ver); - const cl = color || ""; + const cl = color ?? ""; return `t:${threshold}:${cl}:${eh}:${ev}`.replace(/:+$/, ""); }; diff --git a/src/options/unsharpMasking.ts b/src/options/unsharpMasking.ts index 5db3d9be..e968ad4d 100644 --- a/src/options/unsharpMasking.ts +++ b/src/options/unsharpMasking.ts @@ -22,7 +22,8 @@ const build = (options: UnsharpMaskingOptionsPartial): string => { guardIsUndef(unsharpMaskingOpts, "unsharp_masking"); const { mode, weight, divider } = unsharpMaskingOpts; - if (mode) guardIsValidVal(correctMode, mode, "unsharp_masking.mode"); + if (mode !== undefined) + guardIsValidVal(correctMode, mode, "unsharp_masking.mode"); if (weight !== undefined) guardIsNotNum(weight, "unsharp_masking.weight", { addParam: { min: 0, minEqual: true }, @@ -32,9 +33,9 @@ const build = (options: UnsharpMaskingOptionsPartial): string => { addParam: { min: 0, minEqual: true }, }); - const modeStr = mode || ""; - const weightStr = weight || ""; - const dividerStr = divider || ""; + const modeStr = mode ?? ""; + const weightStr = weight ?? ""; + const dividerStr = divider ?? ""; return `ush:${modeStr}:${weightStr}:${dividerStr}`.replace(/:+$/, ""); }; diff --git a/src/options/watermark.ts b/src/options/watermark.ts index 9921731c..36704081 100644 --- a/src/options/watermark.ts +++ b/src/options/watermark.ts @@ -37,19 +37,19 @@ const build = (options: WatermarkOptionsPartial): string => { guardIsNotNum(opacity, "watermark.opacity", { addParam: { min: 0, max: 1 } }); // watermarkOpts.position - if (position) { + if (position !== undefined) { guardIsNotStr(position, "watermark.position"); guardIsValidVal(currentPositions, position, "watermark.position"); } - if (x_offset) guardIsNotNum(x_offset, "watermark.x_offset"); - if (y_offset) guardIsNotNum(y_offset, "watermark.y_offset"); - if (scale) guardIsNotNum(scale, "watermark.scale"); + if (x_offset !== undefined) guardIsNotNum(x_offset, "watermark.x_offset"); + if (y_offset !== undefined) guardIsNotNum(y_offset, "watermark.y_offset"); + if (scale !== undefined) guardIsNotNum(scale, "watermark.scale"); - const pos = position || ""; - const xOffset = x_offset || ""; - const yOffset = y_offset || ""; - const sc = scale || ""; + const pos = position ?? ""; + const xOffset = x_offset ?? ""; + const yOffset = y_offset ?? ""; + const sc = scale ?? ""; return `wm:${opacity}:${pos}:${xOffset}:${yOffset}:${sc}`.replace(/:+$/, ""); }; diff --git a/src/options/watermarkSize.ts b/src/options/watermarkSize.ts index 4deec4da..96677f4a 100644 --- a/src/options/watermarkSize.ts +++ b/src/options/watermarkSize.ts @@ -16,13 +16,13 @@ const build = (options: WatermarkSizeOptionsPartial): string => { guardIsUndef(watermarkSizeOpts, "watermark_size"); const { width, height } = watermarkSizeOpts; - if (width) + if (width !== undefined) guardIsNotNum(width, "watermark_size.width", { addParam: { min: 0 } }); - if (height) + if (height !== undefined) guardIsNotNum(height, "watermark_size.height", { addParam: { min: 0 } }); - const w = width || ""; - const h = height || ""; + const w = width ?? ""; + const h = height ?? ""; return `wms:${w}:${h}`.replace(/:+$/, ""); }; diff --git a/tests/optionsBasic/colorize.test.ts b/tests/optionsBasic/colorize.test.ts index 0359421f..7caea8d7 100644 --- a/tests/optionsBasic/colorize.test.ts +++ b/tests/optionsBasic/colorize.test.ts @@ -70,4 +70,12 @@ describe("colorize", () => { }); }); }); + + describe("build (falsy values)", () => { + it("should throw an error if color is an empty string", () => { + expect(() => build({ col: { opacity: 0.5, color: "" } })).toThrow( + "colorize.color must be 3, 6 or 8 characters long (with alpha)" + ); + }); + }); }); diff --git a/tests/optionsBasic/gravity.test.ts b/tests/optionsBasic/gravity.test.ts index 8da3c290..59413cba 100644 --- a/tests/optionsBasic/gravity.test.ts +++ b/tests/optionsBasic/gravity.test.ts @@ -306,4 +306,13 @@ describe("gravity", () => { }); }); }); + + describe("build (falsy values)", () => { + it("should throw an error if x_offset is 0 and type is not a base type", () => { + // @ts-expect-error: Let's ignore an error (check for users with vanilla js). + expect(() => build({ g: { type: "sm", x_offset: 0 } })).toThrow( + "gravity.type is invalid" + ); + }); + }); }); diff --git a/tests/optionsBasic/jpegOptions.test.ts b/tests/optionsBasic/jpegOptions.test.ts index 50004092..d4d31df0 100644 --- a/tests/optionsBasic/jpegOptions.test.ts +++ b/tests/optionsBasic/jpegOptions.test.ts @@ -145,4 +145,19 @@ describe("jpegOptions", () => { ).toEqual("jpgo::::::7"); }); }); + + describe("build (falsy values)", () => { + it("should throw an error if quant_table is null", () => { + // @ts-expect-error: Let's ignore an error (check for users with vanilla js). + expect(() => build({ jpgo: { quant_table: null } })).toThrow( + "jpeg_options.quant_table is not a number" + ); + }); + + it("should not require progressive when optimize_scans is false", () => { + expect( + build({ jpgo: { progressive: false, optimize_scans: false } }) + ).toEqual("jpgo:false::::false"); + }); + }); }); diff --git a/tests/optionsBasic/monochrome.test.ts b/tests/optionsBasic/monochrome.test.ts index 25439e01..f5f7423b 100644 --- a/tests/optionsBasic/monochrome.test.ts +++ b/tests/optionsBasic/monochrome.test.ts @@ -47,4 +47,12 @@ describe("monochrome", () => { }); }); }); + + describe("build (falsy values)", () => { + it("should throw an error if color is not hexadecimal", () => { + expect(() => build({ mc: { intensity: 0.5, color: "zzz" } })).toThrow( + "monochrome.color must be hexadecimal" + ); + }); + }); }); diff --git a/tests/optionsBasic/pngOptions.test.ts b/tests/optionsBasic/pngOptions.test.ts index c2d4aadf..f6f3c676 100644 --- a/tests/optionsBasic/pngOptions.test.ts +++ b/tests/optionsBasic/pngOptions.test.ts @@ -86,4 +86,19 @@ describe("pngOptions", () => { ).toEqual("pngo:true"); }); }); + + describe("build (falsy values)", () => { + it("should throw an error if quantization_colors is 0", () => { + expect(() => + build({ pngo: { quantize: true, quantization_colors: 0 } }) + ).toThrow("png_options.quantization_colors value can't be less than 2"); + }); + + it("should throw an error if interlaced is null", () => { + // @ts-expect-error: Let's ignore an error (check for users with vanilla js). + expect(() => build({ pngo: { interlaced: null } })).toThrow( + "png_options.interlaced is not a boolean" + ); + }); + }); }); diff --git a/tests/optionsBasic/resize.test.ts b/tests/optionsBasic/resize.test.ts index 8ec9e11a..8efae1e7 100644 --- a/tests/optionsBasic/resize.test.ts +++ b/tests/optionsBasic/resize.test.ts @@ -98,4 +98,26 @@ describe("resize", () => { ).toEqual("rs:auto::150:f"); }); }); + + describe("build (falsy values)", () => { + it("should throw an error if width is null", () => { + expect(() => + // @ts-expect-error: Let's ignore an error (check for users with vanilla js). + build({ rs: { resizing_type: "fit", width: null, height: 100 } }) + ).toThrow("resize.width is not a number"); + }); + + it("should throw an error if resizing_type is an empty string", () => { + // @ts-expect-error: Let's ignore an error (check for users with vanilla js). + expect(() => build({ rs: { resizing_type: "", height: 100 } })).toThrow( + "resize.resizing_type is invalid" + ); + }); + + it("should keep an explicit 0 width", () => { + expect( + build({ rs: { resizing_type: "fit", width: 0, height: 100 } }) + ).toEqual("rs:fit:0:100"); + }); + }); }); diff --git a/tests/optionsBasic/size.test.ts b/tests/optionsBasic/size.test.ts index 448a0211..9ffc793d 100644 --- a/tests/optionsBasic/size.test.ts +++ b/tests/optionsBasic/size.test.ts @@ -85,4 +85,17 @@ describe("size", () => { expect(build({ s: { enlarge: false, height: 150 } })).toEqual("s::150:f"); }); }); + + describe("build (falsy values)", () => { + it("should throw an error if width is null", () => { + expect(() => + // @ts-expect-error: Let's ignore an error (check for users with vanilla js). + build({ s: { width: null, height: 100 } }) + ).toThrow("size.width is not a number"); + }); + + it("should keep an explicit 0 width", () => { + expect(build({ s: { width: 0, height: 100 } })).toEqual("s:0:100"); + }); + }); }); diff --git a/tests/optionsBasic/trim.test.ts b/tests/optionsBasic/trim.test.ts index aa72468f..f39ba20f 100644 --- a/tests/optionsBasic/trim.test.ts +++ b/tests/optionsBasic/trim.test.ts @@ -82,4 +82,12 @@ describe("trim", () => { ).toEqual("t:66::f:f"); }); }); + + describe("build (falsy values)", () => { + it("should throw an error if color is an empty string", () => { + expect(() => build({ t: { threshold: 10, color: "" } })).toThrow( + "trim.color must be 3, 6 or 8 characters long (with alpha)" + ); + }); + }); }); diff --git a/tests/optionsBasic/unsharpMasking.test.ts b/tests/optionsBasic/unsharpMasking.test.ts index 575ca463..d89f8b57 100644 --- a/tests/optionsBasic/unsharpMasking.test.ts +++ b/tests/optionsBasic/unsharpMasking.test.ts @@ -93,4 +93,13 @@ describe("unsharpMasking", () => { ); }); }); + + describe("build (falsy values)", () => { + it("should throw an error if mode is an empty string", () => { + // @ts-expect-error: Let's ignore an error (check for users with vanilla js). + expect(() => build({ ush: { mode: "" } })).toThrow( + "unsharp_masking.mode is invalid" + ); + }); + }); }); diff --git a/tests/optionsBasic/watermark.test.ts b/tests/optionsBasic/watermark.test.ts index ef06b616..fbbbee98 100644 --- a/tests/optionsBasic/watermark.test.ts +++ b/tests/optionsBasic/watermark.test.ts @@ -137,4 +137,26 @@ describe("watermark", () => { ).toEqual("wm:0.8:soea"); }); }); + + describe("build (falsy values)", () => { + it("should throw an error if x_offset is null", () => { + expect(() => + // @ts-expect-error: Let's ignore an error (check for users with vanilla js). + build({ wm: { opacity: 1, position: "ce", x_offset: null } }) + ).toThrow("watermark.x_offset is not a number"); + }); + + it("should throw an error if position is an empty string", () => { + // @ts-expect-error: Let's ignore an error (check for users with vanilla js). + expect(() => build({ wm: { opacity: 1, position: "" } })).toThrow( + "watermark.position is invalid" + ); + }); + + it("should keep an explicit 0 offset", () => { + expect( + build({ wm: { opacity: 1, position: "ce", x_offset: 0, y_offset: 5 } }) + ).toEqual("wm:1:ce:0:5"); + }); + }); }); diff --git a/tests/optionsBasic/watermarkSize.test.ts b/tests/optionsBasic/watermarkSize.test.ts index e5b6f1a4..dca5d20e 100644 --- a/tests/optionsBasic/watermarkSize.test.ts +++ b/tests/optionsBasic/watermarkSize.test.ts @@ -61,4 +61,17 @@ describe("watermarkSize", () => { ); }); }); + + describe("build (falsy values)", () => { + it("should throw an error if width is null", () => { + expect(() => + // @ts-expect-error: Let's ignore an error (check for users with vanilla js). + build({ wms: { width: null, height: 100 } }) + ).toThrow("watermark_size.width is not a number"); + }); + + it("should keep an explicit 0 width", () => { + expect(build({ wms: { width: 0, height: 100 } })).toEqual("wms:0:100"); + }); + }); }); From dda8e4252fb8a768f4b2b63fb49af2149ea4bef7 Mon Sep 17 00:00:00 2001 From: Ivan Buryak Date: Mon, 24 Aug 2026 17:00:26 +0500 Subject: [PATCH 2/2] Make Gravity an exclusive union, drop type suppressions Fields belonging to one gravity variant are now typed as forbidden (`?: never`) on the other variants, so invalid combinations such as `{type: "sm", x_offset: 0}` fail to compile instead of only throwing at runtime. This also makes cross-variant property access legal on the union, removing all six `@ts-expect-error` suppressions from the gravity builder. Runtime behavior is unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014gcu8oHhTciugXNPUYNoqK --- .changeset/exclusive-gravity-union.md | 5 +++++ src/options/gravity.ts | 10 +--------- src/types/gravity.ts | 26 +++++++++++++++++++++----- tests/optionsBasic/gravity.test.ts | 1 + 4 files changed, 28 insertions(+), 14 deletions(-) create mode 100644 .changeset/exclusive-gravity-union.md diff --git a/.changeset/exclusive-gravity-union.md b/.changeset/exclusive-gravity-union.md new file mode 100644 index 00000000..e769c380 --- /dev/null +++ b/.changeset/exclusive-gravity-union.md @@ -0,0 +1,5 @@ +--- +"@imgproxy/imgproxy-js-core": minor +--- + +Make the `Gravity` union exclusive: fields belonging to one gravity variant are now typed as forbidden (`?: never`) on the others, so mixing fields from different variants (such as `{ type: "sm", x_offset: 0 }` or `{ type: "no", x: 0.5 }`) is a compile-time error for TypeScript users instead of only a runtime one. Runtime behavior is unchanged. TypeScript code that previously compiled while passing such invalid combinations will now fail to compile — the same combinations have always thrown at runtime. diff --git a/src/options/gravity.ts b/src/options/gravity.ts index a023257c..d985063e 100644 --- a/src/options/gravity.ts +++ b/src/options/gravity.ts @@ -59,21 +59,13 @@ const build = ( const type = gravityOpts.type; guardIsValidVal(currentAllTypes, type, "gravity.type"); - if ( - // @ts-expect-error: Let's ignore an error. - gravityOpts.x_offset !== undefined || - // @ts-expect-error: Let's ignore an error. - gravityOpts.y_offset !== undefined - ) + if (gravityOpts.x_offset !== undefined || gravityOpts.y_offset !== undefined) guardIsValidVal(currentBaseTypes, type, "gravity.type"); - // @ts-expect-error: Let's ignore an error. if (gravityOpts.class_names && type !== "obj") throw new Error("gravity.class_names can be used only with type obj"); - // @ts-expect-error: Let's ignore an error. if (gravityOpts.class_weights && type !== "objw") throw new Error("gravity.class_weights can be used only with type objw"); if ( - // @ts-expect-error: Let's ignore an error. (gravityOpts.x !== undefined || gravityOpts.y !== undefined) && type !== "fp" ) diff --git a/src/types/gravity.ts b/src/types/gravity.ts index e6f5e4cc..532bf588 100644 --- a/src/types/gravity.ts +++ b/src/types/gravity.ts @@ -199,12 +199,28 @@ interface ObjwGravity { * * @see https://docs.imgproxy.net/generating_the_url?id=gravity */ +type GravityFieldKeys = + | keyof BaseGravity + | keyof SmartGravity + | keyof ObjGravity + | keyof ObjwGravity + | keyof FPGravity; + +/** + * Marks every gravity field that doesn't belong to the variant as + * forbidden (`?: never`), so mixing fields from different variants + * (e.g. `{type: "sm", x_offset: 0}`) is a compile-time error. + */ +type OnlyGravity = T & { + [K in Exclude]?: never; +}; + type Gravity = - | BaseGravity - | SmartGravity - | ObjGravity - | ObjwGravity - | FPGravity; + | OnlyGravity + | OnlyGravity + | OnlyGravity + | OnlyGravity + | OnlyGravity; /** * *Gravity option* diff --git a/tests/optionsBasic/gravity.test.ts b/tests/optionsBasic/gravity.test.ts index 59413cba..b16bf4a2 100644 --- a/tests/optionsBasic/gravity.test.ts +++ b/tests/optionsBasic/gravity.test.ts @@ -138,6 +138,7 @@ describe("gravity", () => { type: "no", // @ts-expect-error: Let's ignore an error (check for users with vanilla js). x: 0.5, + // @ts-expect-error: Let's ignore an error (check for users with vanilla js). y: 0.5, }, })