Skip to content
Closed
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
18 changes: 16 additions & 2 deletions src/utils/case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Ch>` 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 string> = Ch extends Lowercase<Ch>
? false
: true

/**
* Maps a camelCase string literal to snake_case at the type level.
* `"isNsfw"` → `"is_nsfw"`. The inverse of {@link Camelize}.
Expand All @@ -93,13 +107,13 @@ function camelToSnake(key: string): string {
*/
type CamelToSnakeInner<S extends string> =
S extends `${infer Head}${infer Tail}`
? Head extends Uppercase<Head>
? IsUppercaseLetter<Head> extends true
? `_${Lowercase<Head>}${CamelToSnakeInner<Tail>}`
: `${Head}${CamelToSnakeInner<Tail>}`
: S

type CamelToSnake<S extends string> = S extends `${infer Head}${infer Tail}`
? Head extends Uppercase<Head>
? IsUppercaseLetter<Head> extends true
? `${Lowercase<Head>}${CamelToSnakeInner<Tail>}`
: `${Head}${CamelToSnakeInner<Tail>}`
: S
Expand Down
27 changes: 27 additions & 0 deletions test/utils/case.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,33 @@ describe("Snakeize<T>", () => {
type Out = Snakeize<In>
expectTypeOf<Out>().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<In>
expectTypeOf<Out>().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<Snakeize<In>>().toEqualTypeOf<{ _private: number }>()
expect(snakeizeKeysDeep({ _private: 1 })).toEqual({ _private: 1 })
})
})

describe("Camelize<T>", () => {
Expand Down