diff --git a/src/utils/case.ts b/src/utils/case.ts index 5178b64af..d03f91d87 100644 --- a/src/utils/case.ts +++ b/src/utils/case.ts @@ -83,6 +83,20 @@ function camelToSnake(key: string): string { ) } +/** + * Whether a single character earns an underscore in front of it, i.e. whether + * {@link camelToSnake} would emit one. + * + * `Ch extends Uppercase` is the wrong test for this. `Uppercase` is the + * identity on digits and on anything else without a case, so `"1" extends + * Uppercase<"1">` is true and every digit read as an uppercase letter. Comparing + * against `Lowercase` instead is true for exactly the characters `Lowercase` + * actually rewrites, which is the set the runtime `/[A-Z]/` matches. + */ +type IsUppercaseLetter = Ch extends Lowercase + ? false + : true + /** * Maps a camelCase string literal to snake_case at the type level. * `"isNsfw"` → `"is_nsfw"`. The inverse of {@link Camelize}. @@ -93,13 +107,13 @@ function camelToSnake(key: string): string { */ type CamelToSnakeInner = S extends `${infer Head}${infer Tail}` - ? Head extends Uppercase + ? IsUppercaseLetter extends true ? `_${Lowercase}${CamelToSnakeInner}` : `${Head}${CamelToSnakeInner}` : S type CamelToSnake = S extends `${infer Head}${infer Tail}` - ? Head extends Uppercase + ? IsUppercaseLetter extends true ? `${Lowercase}${CamelToSnakeInner}` : `${Head}${CamelToSnakeInner}` : S diff --git a/test/utils/case.spec.ts b/test/utils/case.spec.ts index 6734d5a20..278005b81 100644 --- a/test/utils/case.spec.ts +++ b/test/utils/case.spec.ts @@ -174,6 +174,33 @@ describe("Snakeize", () => { type Out = Snakeize expectTypeOf().toEqualTypeOf<{ my_key: number }>() }) + + test("no underscore before a digit, agreeing with the runtime", () => { + // `Uppercase<"1">` is `"1"`, so the old guard read every digit as an + // uppercase letter and the type promised `chain_2_id` where the runtime + // writes `chain2_id`. Reading `body.chain_2_id` then type-checked and came + // back undefined. `expectTypeOf` is only checked by `check-types`; the + // runtime assertion below is what vitest runs. + type In = { chain2Id: string; erc721TokenId: string } + type Out = Snakeize + expectTypeOf().toEqualTypeOf<{ + chain2_id: string + erc721_token_id: string + }>() + + expect(snakeizeKeysDeep({ chain2Id: "1", erc721TokenId: "2" })).toEqual({ + chain2_id: "1", + erc721_token_id: "2", + }) + }) + + test("a leading underscore is not an uppercase letter either", () => { + // Same root cause: `Uppercase<"_">` is `"_"`. The runtime's `/[A-Z]/` + // leaves the key alone; the type used to add a second underscore. + type In = { _private: number } + expectTypeOf>().toEqualTypeOf<{ _private: number }>() + expect(snakeizeKeysDeep({ _private: 1 })).toEqual({ _private: 1 }) + }) }) describe("Camelize", () => {