Skip to content

[WIP]: add toSnakeCase & toSnakeCaseKeys actions #1030

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: main
Choose a base branch
from

Conversation

EltonLobo07
Copy link
Collaborator

No description provided.

Copy link

vercel bot commented Jan 23, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
valibot ✅ Ready (Inspect) Visit Preview 💬 Add feedback Mar 22, 2025 7:16pm

Copy link

pkg-pr-new bot commented Jan 23, 2025

Open in Stackblitz

npm i https://pkg.pr.new/valibot@1030

commit: d9b588b

Refactor and rename (if required) types, then move them to the right
file based on the usage
@EltonLobo07
Copy link
Collaborator Author

@fabian-hiller I'll have to change the algorithm to transform a string to its snake case version, as I have missed some important edge cases. I'll need your feedback on the new algorithm.

@fabian-hiller
Copy link
Owner

I will try to review it next week.

@EltonLobo07
Copy link
Collaborator Author

EltonLobo07 commented Feb 19, 2025

There are some tricky problems that might have broken the type safety. To make sure the type safety is maintained and to make sure the TS type checker doesn't get overwhelmed, I have loosened the types a bit while reimplementing the action, and I think it is the correct path to take. I'll reply with a detailed message tomorrow which explains the decisions taken.

Once we agree on the new algorithm and the implementation, adding the action to our website will be the only pending work.

@fabian-hiller
Copy link
Owner

Sounds good. I can try to review it on the weekend and give feedback on your code.

@EltonLobo07
Copy link
Collaborator Author

Ideally, when dealing with duplicate keys, this is what I expected:

const Schema = v.pipe(
    v.object({FooBar: v.string(), fooBar: v.boolean()}),
    v.toSnakeCase()
);

const input = {FooBar: "", fooBar: false}; // type: {FooBar: string, fooBar: boolean}
const output = v.parse(Schema, input); // type: {foo_bar: string}

While working on the action, I realized that this expectation was incorrect because:

  1. The key order of an object could be anything (I know the key order is based on the key creation order but not always). So how can we know which key will be transformed first? We can't. Now, I have a workaround that only solves this problem. Even if we implemented that, we still can't solve problem number 2.
  2. Let's assume we can determine the runtime key order. The next challenge is to sync the runtime key order with the type key order which is not possible because {FooBar: string, fooBar: boolean} and {fooBar: boolean, FooBar: string} both types can accept the same object but they will produce a different output type once the keys are transformed. The output types would be {foo_bar: string} and {foo_bar: boolean} respectively. This will break type safety.
    const Schema = v.pipe(
      v.object({ FooBar: v.string(), fooBar: v.boolean() }),
      v.transform<
        { FooBar: string; fooBar: boolean },
        { fooBar: boolean; FooBar: string }
      >((v) => v),
      v.toSnakeCase(),
    );
    
    const output = v.parse(Schema, {
      FooBar: '',
      fooBar: false,
    }); // value: {foo_bar: ""}, type: {foo_bar: boolean}

Additionally, the keyof type operator is not ordered in any way. We can't change the random key iteration order at the type level.

Since it is difficult to sync the type and runtime key order, we avoid it. This is where we add a bit of inconvenience to maintain type safety. At runtime, the last used duplicate key's value will be in the output. At the type level, the duplicate key values will form a union.

const Schema = v.pipe(
  v.object({ FooBar: v.string(), fooBar: v.boolean() }),
  v.toSnakeCase(),
);

const output = v.parse(Schema, {
  FooBar: '',
  fooBar: false,
}); // value: {foo_bar: false}, type: {foo_bar: string | boolean}

@EltonLobo07
Copy link
Collaborator Author

The other issue is related to the key select list. When we have two keys, the number of possible select list tuples is four.

const Schema = v.pipe(
  v.object({ FooBar: v.string(), fooBar: v.boolean() }),
  // union of 4 tuples:
  // ["fooBar"] | ["FooBar"] | ["fooBar", "FooBar"] | ["FooBar", "fooBar"]
  v.toSnakeCase(["fooBar", "FooBar"]),
);

The number of possible tuples increases very rapidly as the number of keys in the object increases. This slows down the type checker. In some cases, the type checker never finishes calculating the possible types.

I recommend making the select list type loose.

type SelectedStringKeys<T extends ObjectInput> = (keyof T & string)[];

@EltonLobo07
Copy link
Collaborator Author

Do you think it is a good idea to expose an additional action related to this PR?
toSnakeCase - an action that converts a string to its snake case form
objectToSnakeCase - this PR's action

@fabian-hiller
Copy link
Owner

Sorry that I don't have a lot of time right now. I will try to review more PRs in about two weeks. Yes, we could provide a toSnakeCase action for strings and something like a toSnakeCaseKeys actions for objects.

@EltonLobo07 EltonLobo07 changed the title [WIP]: add toSnakeCase action [WIP]: add toSnakeCase & toSnakeCaseKeys action Feb 26, 2025
@EltonLobo07 EltonLobo07 changed the title [WIP]: add toSnakeCase & toSnakeCaseKeys action [WIP]: add toSnakeCase & toSnakeCaseKeys actions Feb 26, 2025
While transforming a string to a case such as snake case, the runtime
and type system have to find whitespace characters. To keep the
runtime & related types in sync, create and use a whitespace list.
@EltonLobo07
Copy link
Collaborator Author

Summary

Since there is a lot to discuss, I have summarized everything I know until now.

API usage

toSnakeCase

const Schema = v.pipe(v.string(), v.toSnakeCase());

const output = v.parse(Schema, "hello world");

/*
  output's 
    type : string
    value: hello_world
*/

toSnakeCaseKeys

No selected keys

const Schema = v.pipe(
  v.object({
    fooBar: v.literal('fooBar'),
    'hello world': v.literal('hello world'),
    'HELLO WORLD': v.literal('HELLO WORLD'),
  }),
  v.toSnakeCaseKeys(),
);

const output = v.parse(Schema, {
  fooBar: 'fooBar',
  'hello world': 'hello world',
  'HELLO WORLD': 'HELLO WORLD',
});

/*
  output's 
    type : { foo_bar: "fooBar", hello_world: "hello world" | "HELLO WORLD" }
    value: { foo_bar: "fooBar", hello_world: "HELLO WORLD" }
*/

The latest key's value is used when dealing with duplicate keys. A key is said to be the latest if it appears at the end
while iterating over the object's keys using Object.keys

Selected keys

const Schema = v.pipe(
  v.object({
    fooBar: v.literal('fooBar'),
    'hello world': v.literal('hello world'),
    'HELLO WORLD': v.literal('HELLO WORLD'),
  }),
  v.toSnakeCaseKeys(["hello world", "HELLO WORLD"]),
);

const output = v.parse(Schema, {
  fooBar: 'fooBar',
  'hello world': 'hello world',
  'HELLO WORLD': 'HELLO WORLD',
});

/*
  output's 
    type : { fooBar: "fooBar", hello_world: "hello world" | "HELLO WORLD" }
    value: { fooBar: "fooBar", hello_world: "HELLO WORLD" }
*/

The select list's type that is passed to toSnakeCaseKeys is loose on purpose. For more information, read this comment

toSnakeCaseKeys edge cases

All duplicates are optional

Since all duplicates are optional, the transformed key is also optional

const Schema = v.pipe(
  v.object({
    'hello world': v.optional(v.literal('hello world')),
    'HELLO WORLD': v.optional(v.literal('HELLO WORLD')),
  }),
  v.toSnakeCaseKeys(),
);

const output = v.parse(Schema, { });

/*
  output's 
    type : { hello_world?: "hello world" | "HELLO WORLD" }
    value: { }
*/

Not all duplicates are optional

There will always be a key-value pair, so the transformed key is not optional

const Schema = v.pipe(
  v.object({
    'hello world': v.optional(v.literal('hello world')),
    'HELLO WORLD': v.literal('HELLO WORLD'),
  }),
  v.toSnakeCaseKeys(),
);

const output = v.parse(Schema, { "HELLO WORLD": "HELLO WORLD" });

/*
  output's 
    type : { hello_world: "hello world" | "HELLO WORLD" }
    value: { hello_world: "HELLO WORLD" }
*/

At least one duplicate is readonly

Since it is better not to rely on the order of the keys in the type system, we don't really know if the readonly key was the actual key that is present in the output. Hence, the action marks the transformed key readonly

const Schema = v.pipe(
  v.object({
    'hello world': v.pipe(v.literal('hello world'), v.readonly()),
    'HELLO WORLD': v.literal('HELLO WORLD'),
  }),
  v.toSnakeCaseKeys(),
);

const output = v.parse(Schema, {
  'hello world': 'hello world',
  'HELLO WORLD': 'HELLO WORLD',
});

/*
  output's 
    type : { readonly hello_world: "hello world" | "HELLO WORLD" }
    value: { hello_world: "HELLO WORLD" }
*/

Duplicate number-string keys

Even though the example below returns a value, this is an error in the type world. The returned type will be never

const Schema = v.pipe(
  v.object({
    1: v.literal(1),
    ' 1 ': v.literal(' 1 '),
  }),
  v.toSnakeCaseKeys(),
);

const output = v.parse(Schema, { 1: 1, " 1 ": " 1 " });

/*
  output's 
    type : never
    value: { 1: " 1 " }
*/

Unexplored

@fabian-hiller I haven't explored working with the schemas mentioned below, but we will have to decide how to work with them. If you can help me decide what should be the type of the output for the following cases, that would be great.

looseObject

const Schema = v.pipe(
  v.looseObject({
    "hello world": v.literal("hello world")
  }),
  v.toSnakeCaseKeys(),
);

const output = v.parse(Schema, { "hello world": "hello world", "hello World": 123 });

// output's value: { hello_world: 123 }

objectWithRest

const Schema = v.pipe(
  v.objectWithRest(
    {
      'hello world': v.literal('hello world'),
    },
    v.boolean(),
  ),
  v.toSnakeCaseKeys(),
);

const output = v.parse(Schema, {
  'hello world': 'hello world',
  'hello World': true
});

// output's value: { hello_world: true }

@fabian-hiller fabian-hiller added this to the v1.1 milestone Apr 20, 2025
@fabian-hiller fabian-hiller requested a review from Copilot April 20, 2025 20:38
Copy link

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR introduces two new actions—toSnakeCase and toSnakeCaseKeys—to convert strings and object keys to snake case. Key changes include the addition of utility functions for snake case transformations, corresponding type definitions/tests, and the integration of these actions into the actions index.

Reviewed Changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated no comments.

Show a summary per file
File Description
library/src/utils/index.ts Re-exports new snake case utilities
library/src/utils/_snakeCase/* Implements the snake case transformation logic
library/src/utils/_caseTransform/* Adds supporting type definitions and helper functions
library/src/types/utils.ts Introduces additional type helpers (e.g. IsNever, OptionalKeys)
library/src/actions/toSnakeCase* Implements the toSnakeCase action and its tests
library/src/actions/toSnakeCaseKeys* Implements the toSnakeCaseKeys action and its tests
library/src/actions/index.ts Updates the actions index to re-export the new actions
Comments suppressed due to low confidence (1)

library/src/utils/_snakeCase/_snakeCase.ts:10

  • [nitpick] Consider whether the leading underscore in _snakeCase is necessary for a publicly re-exported function. If it is not intended to denote a private member, renaming it to snakeCase could improve clarity.
export const _snakeCase: (input: string) => string = _createToTargetCase(

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants