'Is it possible to use mapped types in Typescript to change a types key names?
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.
Solution 1:[1]
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_
Solution 2:[2]
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.
Assuming that you have pre-existing snake case attributes such as column_12 or row_512_visible, the camel case versions respectively column12 and row512Visible, if we want the transformation to be consistent either direction, 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.
*/
export const snakeCaseKeys = <T extends SnakeCaseInputType>(obj: T): 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, key: PropertyKey, collection) => {
/* eslint-disable no-param-reassign */
if (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);
} else if (typeof key === 'symbol') {
// DO NOTHING - satisfy typescript type inference
} else if (key.includes('__')) {
result[snakeCaseDoubleUnderscores(key)] = parseValue(value);
} else {
result[snakeCase(`${key}`)] = parseValue(value);
}
/* 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
}
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
| Solution | Source |
|---|---|
| Solution 1 | vgharz |
| Solution 2 | Kyle |
