Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/humble-lizards-judge.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@

* **BREAKING**: The `boolean` keyword has been removed. Universal boolean coercion is now handled by the `arkenv` package.
* **BREAKING**: The `port` keyword has been changed from a `string -> number` morph to a pure `number` refinement. Numeric coercion is now handled centrally.
* Added `maybeParsedNumber` and `maybeParsedBoolean` internal morphs to support central coercion.
* Added `maybeParsedNumber` and `maybeParsedBoolean` internal morphs to support central coercion (including specific "NaN" support).
22 changes: 22 additions & 0 deletions packages/arkenv/src/utils/coerce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,4 +191,26 @@ describe("coerce", () => {
VITE_MY_NUMBER_MANUAL: 456,
});
});

it("should coerce 'NaN' string to NaN number", () => {
const schema = type({
VAL: "number.NaN",
});
const coercedSchema = coerce(schema);
const result = coercedSchema({ VAL: "NaN" });
expect(result).not.toBeInstanceOf(ArkErrors);
if (result instanceof ArkErrors) return;
expect(result.VAL).toBeNaN();
});

it("should fail to validate 'NaN' string with standard 'number' keyword", () => {
const schema = type({
VAL: "number",
});
// Coercion happens ("NaN" -> NaN), but "number" checks and rejects NaN
const coercedSchema = coerce(schema);
const result = coercedSchema({ VAL: "NaN" });
expect(result).toBeInstanceOf(ArkErrors);
expect(result.toString()).toContain("VAL must be a number (was NaN)");
});
});
10 changes: 5 additions & 5 deletions packages/arkenv/src/utils/coerce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ const applyCoercion = (data: unknown, targets: CoercionTarget[]) => {
// If root data needs coercion (e.g. root schema is number/boolean), handle it
if (targets.some((t) => t.path.length === 0)) {
const asNumber = maybeParsedNumber(data);
if (typeof asNumber === "number" && !Number.isNaN(asNumber)) {
if (typeof asNumber === "number") {
return asNumber;
}
return maybeParsedBoolean(data);
Expand Down Expand Up @@ -146,7 +146,7 @@ const applyCoercion = (data: unknown, targets: CoercionTarget[]) => {
for (let i = 0; i < current.length; i++) {
const original = current[i];
const asNumber = maybeParsedNumber(original);
if (typeof asNumber === "number" && !Number.isNaN(asNumber)) {
if (typeof asNumber === "number") {
current[i] = asNumber;
} else {
current[i] = maybeParsedBoolean(original);
Expand All @@ -165,16 +165,16 @@ const applyCoercion = (data: unknown, targets: CoercionTarget[]) => {
for (let i = 0; i < original.length; i++) {
const item = original[i];
const asNumber = maybeParsedNumber(item);
if (typeof asNumber === "number" && !Number.isNaN(asNumber)) {
if (typeof asNumber === "number") {
original[i] = asNumber;
} else {
original[i] = maybeParsedBoolean(item);
}
}
} else {
const asNumber = maybeParsedNumber(original);
// If numeric parsing didn't change type (still string) or is NaN/invalid, try boolean
if (typeof asNumber === "number" && !Number.isNaN(asNumber)) {
// If numeric parsing didn't produce a number, try boolean coercion
if (typeof asNumber === "number") {
record[lastKey] = asNumber;
} else {
record[lastKey] = maybeParsedBoolean(original);
Expand Down
10 changes: 6 additions & 4 deletions packages/internal/keywords/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import { type } from "arktype";
*/
export const maybeParsedNumber = type("unknown").pipe((s) => {
if (typeof s === "number") return s;
if (typeof s !== "string" || s.trim() === "") return s;
const n = Number(s);
if (Number.isNaN(n) && s !== "NaN") return s;
return n;
if (typeof s !== "string") return s;
const trimmed = s.trim();
if (trimmed === "") return s;
if (trimmed === "NaN") return Number.NaN;
const n = Number(trimmed);
return Number.isNaN(n) ? s : n;
});

/**
Expand Down