Reputation: 848
I have a ton of generated typescript types from proto files. These types properties are in camel case and my proto files (and api) are in snake case.
I would like to avoid transforming my api data to camel case in order to satisfy my type constraints. I am trying to figure out a way to use mapped types to change a types keys from camel to snake case.
For example:
Generated Type
type g = {
allTheNames: string
}
type SnakePerson = {
firstName: string
lastName: string
name: g
Desired Type
{
first_name: string
last_name: string
g: { all_the_names: string }
}
I made an attempt but I am fairly new to typescript and mapped types
type ToSnakeCase<K extends string, T> = {
[snakeCase([P in K])]: T[P]
}
Any help including telling me this is not possible would be much appreciated.
Upvotes: 14
Views: 7464
Reputation: 11
The answer provided by @vgharz is the only one I have seen so far that has the ability to handle numbers in whichever way you want.
If you have camel case keys column12
and row512Visible
with respective snake case attributes column_12
or row_512_visible
, we can add a type of string literals of every digit.
type AlphanumericDigits = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '0';
Then, @vgharz 's SnakeUnderscore type becomes:
/**
* Return underscore if it is allowed between provided characters,
* trail and lead underscore are allowed, empty string is considered
* as the beginning of a string.
*/
type SnakeUnderscore<
First extends PropertyKey,
Second extends PropertyKey
> = First extends AlphanumericDigits
? Second extends UpperAlphabetic
? '_'
: ''
: First extends UpperAlphabetic | '' | '_'
? ''
: Second extends UpperAlphabetic | AlphanumericDigits
? '_'
: '';
Now, if we have fields like __typename
(graphql) or salesforce_custom_field__c
, then we might want to leave double underscores alone. This is, again, so that the transformation is consistent both ways.
You could do something like:
type CamelToSnakeCase<
S extends PropertyKey,
Previous extends PropertyKey = ''
> = S extends number
? S
: S extends `__${infer K}`
? `__${CamelToSnakeCase<K>}`
: S extends `${infer J}__${infer L}`
? `${CamelToSnakeCase<J>}__${CamelToSnakeCase<L>}`
: S extends `${infer First}${infer Second}${infer Rest}`
? `${SnakeUnderscore<Previous, First>}${Lowercase<First>}${SnakeUnderscore<
First,
Second
>}${Lowercase<Second>}${CamelToSnakeCase<Rest, First>}`
: S extends `${infer First}`
? `${SnakeUnderscore<Previous, First>}${Lowercase<First>}`
: '';
NOTE: Your function still has to handle everything correctly! Lodash snakeCase function is going to strip all double underscores, for example. (you can do a check for '__' in the string and then split on that and perform the lodash snakeCase on each part in that instance)
Finally, if we combine this with other examples for converting deeply nested object keys, we get something like:
export type SnakeCaseInputType = Record<PropertyKey, any> | Array<any>;
type UpperAlphabetic =
| 'A'
| 'B'
| 'C'
| 'D'
| 'E'
| 'F'
| 'G'
| 'H'
| 'I'
| 'J'
| 'K'
| 'L'
| 'M'
| 'N'
| 'O'
| 'P'
| 'Q'
| 'R'
| 'S'
| 'T'
| 'U'
| 'V'
| 'W'
| 'X'
| 'Y'
| 'Z';
type AlphanumericDigits = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '0';
/**
* Return underscore if it is allowed between provided characters,
* trail and lead underscore are allowed, empty string is considered
* as the beginning of a string.
*/
type SnakeUnderscore<
First extends PropertyKey,
Second extends PropertyKey
> = First extends AlphanumericDigits
? Second extends UpperAlphabetic
? '_'
: ''
: First extends UpperAlphabetic | '' | '_'
? ''
: Second extends UpperAlphabetic | AlphanumericDigits
? '_'
: '';
/**
* Convert string literal type to snake_case
*/
type CamelToSnakeCase<
S extends PropertyKey,
Previous extends PropertyKey = ''
> = S extends number
? S
: S extends `__${infer K}`
? `__${CamelToSnakeCase<K>}`
: S extends `${infer J}__${infer L}`
? `${CamelToSnakeCase<J>}__${CamelToSnakeCase<L>}`
: S extends `${infer First}${infer Second}${infer Rest}`
? `${SnakeUnderscore<Previous, First>}${Lowercase<First>}${SnakeUnderscore<
First,
Second
>}${Lowercase<Second>}${CamelToSnakeCase<Rest, First>}`
: S extends `${infer First}`
? `${SnakeUnderscore<Previous, First>}${Lowercase<First>}`
: '';
// eslint-disable-next-line @typescript-eslint/ban-types
export type CamelToSnakeCaseNested<T> = T extends Function | RegExp | Date
? T
: T extends (infer E)[]
? CamelToSnakeCaseNested<E>[]
: T extends SnakeCaseInputType
? {
[K in keyof T as CamelToSnakeCase<Extract<K, PropertyKey>>]: CamelToSnakeCaseNested<T[K]>;
}
: T;
We can build a function using lodash transform method to ALMOST match the type transformation we defined previously. However, as you will see in the test object at the end, lodash strips characters such as trailing and leading non letters/digits such as question marks. It should be technically possible to have a type of string literals of all of these characters and skip over them in the type mapping, but we are starting to get into edge cases rarely seen if properties and variables are named sanely...
// Lodash removes any instance of '__' (which we do not want, eg. '__typename' or some answers interface keys), so we camelCase the substrings then put any instances of '__' back in
const snakeCaseDoubleUnderscores = (string: string) => {
const parts = string.split('__');
return parts.map((part) => snakeCase(part)).join('__');
};
const parseValue = <T extends SnakeCaseInputType>(value: T) =>
isObject(value) && typeof value !== 'function'
? snakeCaseKeys<T>(value as T) // eslint-disable-line no-use-before-define
: value;
/**
* This will convert all snake case keys to camel case. Input can be an object or an array of objects.
* Symbols will NOT be handled, even if the type definition says it can. This is a tradeoff so that type inference works.
* @param obj - Object, or array of objects, to transform all snake case keys to camel case.
* @param excludedKeys - General list of excluded keys that should not be changed to camel case.
*/
export const snakeCaseKeys = <T extends SnakeCaseInputType>(
obj: T,
excludedKeys?: string[]
): CamelToSnakeCaseNested<T> => {
// result must be Record, not T, or you get a type index error on "result[camelKey]"
const transformed = transform<T, Record<PropertyKey, any>>(
obj,
(result, value: any, key: PropertyKey, collection) => {
/* eslint-disable no-param-reassign */
if (
excludedKeys?.find((excludedKey) => excludedKey === key) ||
isArray(collection) ||
typeof key === 'number'
) {
// array keys are numbers so don't snake case the key
// also handle designated excluded keys the same way
result[key] = parseValue(value, excludedKeys);
} else if (typeof key === 'symbol') {
// DO NOTHING - satisfy typescript type inference
} else if (key.includes('__')) {
result[snakeCaseDoubleUnderscores(key)] = parseValue(value, excludedKeys);
} else {
result[snakeCase(`${key}`)] = parseValue(value, excludedKeys);
}
/* eslint-enable no-param-reassign */
}
);
return transformed as CamelToSnakeCaseNested<T>;
};
If you don't want to do anything special for "__" (most of the time you won't) just remove those specific parts.
Camel case object such as this:
{
"3": 4,
"5": {
"someOtherProp": "The Value",
"yetAnotherProp": "The Yet Another Value",
"oneLastProp": {
"moreNesting": "!!!!",
"because": {
"whyNot": {
"?startsWithQuestionMarkAndEnds?": true
}
}
}
},
"a1": "1",
"a2": 2,
"a4": {
"b1": "b1",
"b2": 2,
"b3": [
"1",
"2",
"3"
],
"b4": "hello_there",
"b5": {
"c1": {
"d1": {
"e1": {}
}
}
}
},
"a5": [
1,
2,
3,
4,
5
],
"a6": [
{
"arrayObjectProp": 1,
"arrayObjectAnotherOne": {
"ohNo": "no",
"plz": {
"stop": true
}
}
}
],
"existing_snake_case_prop": true,
"existing__double__underscores": true,
"column45Visible": true
}
will subsequently get transformed to:
{
"3": 4,
"5": {
"some_other_prop": "The Value",
"yet_another_prop": "The Yet Another Value",
"one_last_prop": {
"more_nesting": "!!!!",
"because": {
"why_not": {
"starts_with_question_mark_and_ends": true
}
}
}
},
"a_1": "1",
"a_2": 2,
"a_4": {
"b_1": "b1",
"b_2": 2,
"b_3": [
"1",
"2",
"3"
],
"b_4": "hello_there",
"b_5": {
"c_1": {
"d_1": {
"e_1": {}
}
}
}
},
"a_5": [
1,
2,
3,
4,
5
],
"a_6": [
{
"array_object_prop": 1,
"array_object_another_one": {
"oh_no": "no",
"plz": {
"stop": true
}
}
}
],
"existing_snake_case_prop": true,
"existing__double__underscores": true,
"column_45_visible": true
}
Edit: Also sharing the reverse, snake to camel case:
// This is stitched from multiple stack overflows, plus the following enhancements:
// - narrows the input type from "object" to Record<PropertyKey, any> | Array<any>
// - widens keys to include numbers and symbols so that type inference works 100% (even if symbols will never be iterated over)
// - properly handles function values and types ( toString: () => ... would become toString: {})
// 1. - Input Type
export type CamelizeInputType = Record<PropertyKey, any> | Array<any>;
// Given key/value of {"some_key":"value"}:
// first, if key starts with __ or has __, then ignore __ for transformation but preserve the characters in the type
// if key extends some string with "_", then we scoop T, capitalize U, but perform a recursive transform on U
// so that additional "_" can be processed (for example, three_little_pigs -> threeLittle_pigs -> threeLittlePigs)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export type SnakeToCamelCase<S extends PropertyKey> = S extends number
? S
: S extends `__${infer K}`
? `__${SnakeToCamelCase<K>}`
: S extends `${infer J}__${infer L}`
? `${SnakeToCamelCase<J>}__${SnakeToCamelCase<L>}`
: S extends `${infer T}_${infer U}`
? `${T}${Capitalize<SnakeToCamelCase<U>>}`
: S;
// Below type explained:
// If T is function we want T (no transformation)
// next: if T is array we want the camel cased transformation done on any objects in the array
// next: if T is any other object, apply the came cased transformation
// finally: no transformation for the final recursion, where T would be the value of the bottom most key in some nested structure
// eslint-disable-next-line @typescript-eslint/ban-types
export type SnakeToCamelCaseNested<T> = T extends Function | RegExp | Date
? T
: T extends (infer E)[]
? SnakeToCamelCaseNested<E>[]
: T extends CamelizeInputType
? {
[K in keyof T as SnakeToCamelCase<Extract<K, PropertyKey>>]: SnakeToCamelCaseNested<T[K]>;
}
: T;
And attempt at the actual function:
// Lodash removes any instance of '__' (which we do not want, eg. '__typename' or some other specific keys), so we camelCase the substrings then put any instances of '__' back in
const camelizeDoubleUnderscores = (string: string) => {
const parts = string.split('__');
return parts.map((part) => camelCase(part)).join('__');
};
const parseValue = <T extends CamelizeInputType>(value: T, excludedKeys?: string[]) =>
isObject(value) && typeof value !== 'function'
? camelizeKeys<T>(value as T, excludedKeys)
: value;
/**
* This will convert all snake case keys to camel case. Input can be an object or an array of objects.
* Symbols will NOT be handled, even if the type definition says it can!!! This is a tradeoff so that type inference works.
* @param obj - Object, or array of objects, to transform all snake case keys to camel case.
* @param excludedKeys - General list of excluded keys that should not be changed to camel case.
*/
export const camelizeKeys = <T extends CamelizeInputType>(
obj: T,
excludedKeys?: string[]
): SnakeToCamelCaseNested<T> => {
// result must be Record, not T, or you get a type index error on "result[camelKey]"
const transformed = transform<T, Record<PropertyKey, any>>(
obj,
(result, value: any, key: PropertyKey, collection) => {
/* eslint-disable no-param-reassign */
if (
excludedKeys?.find((excludedKey) => excludedKey === key) ||
isArray(collection) ||
typeof key === 'number'
) {
// array keys are numbers so don't camel case the key
// also handle designated excluded keys the same way
result[key] = parseValue(value, excludedKeys);
} else if (typeof key === 'symbol') {
// DO NOTHING - satisfy typescript type inference
} else if (key.includes('__')) {
result[camelizeDoubleUnderscores(key)] = parseValue(value, excludedKeys);
} else {
result[camelCase(`${key}`)] = parseValue(value, excludedKeys);
}
/* eslint-enable no-param-reassign */
}
);
return transformed as SnakeToCamelCaseNested<T>;
};
Upvotes: 1
Reputation: 327724
Update for TS4.5+
Now TypeScript has tail recursion elimination on conditional types, meaning that it is possible to write a version of CamelToSnake
which can operate on long strings without running into recursion depth limits, as the compiler will be able to evaluate the type iteratively instead of recursively. Here's a version that will work:
type CamelToSnake<T extends string, P extends string = ""> = string extends T ? string :
T extends `${infer C0}${infer R}` ?
CamelToSnake<R, `${P}${C0 extends Lowercase<C0> ? "" : "_"}${Lowercase<C0>}`> : P
And you can test it out on... well, quite long strings, and it works flawlessly!
type Wow = CamelToSnake<"itWasTheBestOfTimesItWasTheWorstOfTimesItWasTheAgeOfWisdomItWasTheAgeOfFoolishnessItWasTheEpochOfBeliefItWasTheEpochOfIncredulityItWasTheSeasonOfLightItWasTheSeasonOfDarknessItWasTheSpringOfHopeItWasTheWinterOfDespairWeHadEverythingBeforeUsWeHadNothingBeforeUsWeWereAllGoingDirectToHeavenWeWereAllGoingDirectTheOtherWayInShortThePeriodWasSoFarLikeThePresentPeriodThatSomeOfItsNoisiestAuthoritiesInsistedOnItsBeingReceivedForGoodOrForEvilInTheSuperlativeDegreeOfComparisonOnly">
// type Wow = "it_was_the_best_of_times_it_was_the_worst_of_times_it_was_the_age_of_wisdom_it_was_the_age_of_foolishness_it_was_the_epoch_of_belief_it_was_the_epoch_of_incredulity_it_was_the_season_of_light_it_was_the_season_of_darkness_it_was_the_spring_of_hope_it_was_the_winter_of_despair_we_had_everything_before_us_we_had_nothing_before_us_we_were_all_going_direct_to_heaven_we_were_all_going_direct_the_other_way_in_short_the_period_was_so_far_like_the_present_period_that_some_of_its_noisiest_authorities_insisted_on_its_being_received_for_good_or_for_evil_in_the_superlative_degree_of_comparison_only"
You will be able to use type CamelKeysToSnake<T>
or RecursiveSnakification<T>
below as before.
Original answer for TypeScript 4.1 through 4.4:
TypeScript 4.1's introduction of template literal types and mapped as
clauses and recursive conditional types does allow you to implement a type function to convert camel-cased object keys to snake-cased keys, although this sort of string-parsing code tends to be difficult on the compiler and hits some rather shallow limits, unfortunately.
First we need a CamelToSnake<T>
that takes a camel-cased string literal for T
and produces a snake-cased version. The "simplest" implementation of that looks something like:
type CamelToSnake<T extends string> = string extends T ? string :
T extends `${infer C0}${infer R}` ?
`${C0 extends Lowercase<C0> ? "" : "_"}${Lowercase<C0>}${CamelToSnake<R>}` :
"";
Here we are parsing T
character-by-character. If the character is not lowercase, we insert an underscore. Then we append a lowercase version of the character, and continue. Once we have SnakeToCase
we can do the key mapping (using the as
clauses in mapped types):
type CamelKeysToSnake<T> = {
[K in keyof T as CamelToSnake<Extract<K, string>>]: T[K]
}
(Edit: if you need to map the keys recursively down through json-like objects, you can instead use
type RecursiveSnakification<T> = T extends readonly any[] ?
{ [K in keyof T]: RecursiveSnakification<T[K]> } :
T extends object ? {
[K in keyof T as CamelToSnake<Extract<K, string>>]: RecursiveSnakification<T[K]>
} : T
but for the example type given in the question, a non-recursive mapped type will suffice. )
You can see this work on your example types:
interface SnakePerson {
firstName: string
lastName: string
}
type CamelPerson = CamelKeysToSnake<SnakePerson>
/* type CamelPerson = {
first_name: string;
last_name: string;
} */
Unfortunately, if your key names are longer than about fifteen characters, the compiler loses its ability to recurse with the simplest CamelToSnake
implementation:
interface SnakeLengths {
abcdefghijklmnO: boolean;
abcdefghijklmnOP: boolean;
abcdefghijklmnOPQ: boolean;
}
type CamelLengths = CamelKeysToSnake<SnakeLengths>
/* type CamelLengths = {
abcdefghijklmn_o: boolean;
abcdefghijklmn_op: boolean; // wrong!
// gone!!!
} */
The sixteen-character key gets mapped incorrectly, and anything longer disappears entirely. To address this you can start making CamelToSnake
more complicated; for example, to grab bigger chunks:
type CamelToSnake<T extends string> = string extends T ? string :
T extends `${infer C0}${infer C1}${infer R}` ?
`${C0 extends Lowercase<C0> ? "" : "_"}${Lowercase<C0>}${C1 extends Lowercase<C1> ? "" : "_"}${Lowercase<C1>}${CamelToSnake<R>}` :
T extends `${infer C0}${infer R}` ?
`${C0 extends Lowercase<C0> ? "" : "_"}${Lowercase<C0>}${CamelToSnake<R>}` :
"";
This pulls off characters two-by-two instead of one-by-one, and only falls back to the one-by-one version if you have fewer than two characters left. This works for strings up to about 30 characters:
interface SnakeLengths {
abcdefghijklmnO: boolean;
abcdefghijklmnOP: boolean;
abcdefghijklmnOPQ: boolean;
abcdefghijklmnopqrstuvwxyzabcD: boolean
abcdefghijklmnopqrstuvwxyzabcDE: boolean
abcdefghijklmnopqrstuvwxyzabcDEF: boolean
abcdefghijklmnopqrstuvwxyzabcDEFG: boolean
}
type CamelLengths = CamelKeysToSnake<SnakeLengths>
/* type CamelLengths = {
abcdefghijklmn_o: boolean;
abcdefghijklmn_o_p: boolean;
abcdefghijklmn_o_p_q: boolean;
abcdefghijklmnopqrstuvwxyzabc_d: boolean;
abcdefghijklmnopqrstuvwxyzabc_de: boolean; // wrong!
abcdefghijklmnopqrstuvwxyzabc_def: boolean; // wrong!
// gone!
}*/
That's probably enough for most uses. If not, you could go back and try pulling off characters three at a time instead of two at a time. Or you could try to sidestep the character-by-character recursion and write something that breaks a string at the first uppercase character, like in this GitHub comment, but that runs into other similar issues.
The point is, TS4.1 gives you enough tools to pretty much do this, but not enough to do it without some tweaking and thought.
Upvotes: 34
Reputation: 101
I made my own implementation based on jcalz's answer (Thank you so much for the explanation!) , it also considers other cases like consecutive capitals, numbers, leading and trailing underscores, and the others described in the code below. Hope you find it useful
type UpperAlphabetic = 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z';
/**
* Convert string literal type to snake_case
*/
type Snakecase<S extends string> = ToSnakecase<S, "">;
type ToSnakecase<S extends string, Previous extends string> =
S extends `${infer First}${infer Second}${infer Rest}`
? `${SnakeUnderscore<Previous, First>}${Lowercase<First>}${SnakeUnderscore<First, Second>}${Lowercase<Second>}${ToSnakecase<Rest, First>}`
: S extends `${infer First}`? `${SnakeUnderscore<Previous, First>}${Lowercase<First>}` : ""
/**
* Return underscore if it is allowed between provided characters,
* trail and lead underscore are allowed, empty string is considered
* as the beginning of a string.
*/
type SnakeUnderscore<First extends string, Second extends string> =
First extends UpperAlphabetic | "" | "_"
? ""
: Second extends UpperAlphabetic
? "_"
: "";
type Numbers = Snakecase<"camelCaseWithNumbers123">; // camel_case_with_numbers123
type CamelCase = Snakecase<"regularCamelCase">; // regular_camel_case
type PascalCase = Snakecase<"RegularPascalCase">; // regular_pascal_case
type ConsecutiveCapitals = Snakecase<"NodeJS">; // node_js
type SnakeCase = Snakecase<"snake_case">; // snake_case
type AllCaps = Snakecase<"ALL_CAPS_CONSTANT_NAME">; // all_caps_constant_name
type LeadingAndTrailingUnderscore = Snakecase<"_MyVariableName_">; // _my_variable_name_
Upvotes: 1