skip to Main Content

I have the following function for changing object key casing:

export enum CasingPattern {
  SNAKE,
  CAMEL,
}

export const toSnakeCase = (camelCased: string) => {
  const replaced = camelCased.replace(
    new RegExp(CAMEL_CASE_PATTERN, 'g'),
    (match) => {
      const arr = match.split('');
      const last = arr.pop();
      const parsed = arr.join('') + '_' + last?.toLowerCase();
      return parsed;
    },
  );

  return replaced;
};

export const toCamelCase = (snakeCased: string) => {
  const replaced = snakeCased.replace(
    new RegExp(SNAKE_CASE_PATTERN, 'g'),
    (match) => {
      const [lastLetter, firstLetter] = match.split('_');
      const capitalize = firstLetter.toUpperCase();
      return lastLetter + capitalize;
    },
  );

  return replaced;
};

export const jsonCasingParser = (
  jsonPayload: unknown,
  targetPattern: CasingPattern,
) => {
  const isString = typeof jsonPayload === 'string';
  const stringified = isString ? jsonPayload : JSON.stringify(jsonPayload);

  const replaced = stringified.replace(
    new RegExp(JSON_VARIABLE_PATTERN, 'g'),
    (match) => {
      if (targetPattern === CasingPattern.SNAKE) {
        return toSnakeCase(match);
      }

      if (targetPattern === CasingPattern.CAMEL) {
        return toCamelCase(match);
      }

      return match;
    },
  );

  if (isString) {
    return replaced;
  }

  const parsed = JSON.parse(replaced);

  return parsed;
};

CONDITIONS:

  1. if a string is passed to jsonPayload(an object stringified), it should return a string
  2. if an object is passed to jsonPayload and the targetPattern is CasingPattern.CAMEL the return type should be the jsonPayload object with their property keys transformed to camelCase,
  3. if an object is passed to jsonPayload and the targetPattern is CasingPattern.SNAKE the return type should be the jsonPayload object with their properties transformed to snake_case
jsonCasingParser('{ "propOne": "one" }', CasingPattern.CAMEL) // return string
jsonCasingParser({ propOne: "one" }', CasingPattern.SNAKE) // return { prop_one: string } 
jsonCasingParser({ prop_one: "one" }', CasingPattern.CAMEL) // return { propOne: string } 

I should get one narrowed type depending on the parameters passed to jsonCasingParser function.

2

Answers


  1. To handle a function return type conditioned by the parameters in TypeScript, you can use function overloads. This allows you to specify multiple function signatures for a single function, and TypeScript will choose the correct signature based on how the function is called.

    Here’s how you can define the overloaded signatures for your jsonCasingParser function:

    export enum CasingPattern {
      SNAKE,
      CAMEL,
    }
    
    // Overload signatures
    export function jsonCasingParser(jsonPayload: string, targetPattern: CasingPattern): string;
    export function jsonCasingParser(jsonPayload: Record<string, any>, targetPattern: CasingPattern.CAMEL): Record<string, any>;
    export function jsonCasingParser(jsonPayload: Record<string, any>, targetPattern: CasingPattern.SNAKE): Record<string, any>;
    
    // Function implementation
    export function jsonCasingParser(
      jsonPayload: unknown,
      targetPattern: CasingPattern,
    ): string | Record<string, any> {
      const isString = typeof jsonPayload === 'string';
      const stringified = isString ? jsonPayload as string : JSON.stringify(jsonPayload);
    
      const replaced = stringified.replace(
        new RegExp(JSON_VARIABLE_PATTERN, 'g'),
        (match) => {
          if (targetPattern === CasingPattern.SNAKE) {
            return toSnakeCase(match);
          }
    
          if (targetPattern === CasingPattern.CAMEL) {
            return toCamelCase(match);
          }
    
          return match;
        },
      );
    
      if (isString) {
        return replaced;
      }
    
      const parsed = JSON.parse(replaced);
    
      return parsed;
    }
    

    With these overloads, TypeScript will infer the correct return type based on how you call the function:

    If you pass in a string for jsonPayload, the return type will always be string.
    If you pass in an object for jsonPayload and CasingPattern.CAMEL for targetPattern, the return type will be Record<string, any>.
    If you pass in an object for jsonPayload and CasingPattern.SNAKE for targetPattern, the return type will also be Record<string, any>.

    If you want to avoid the use of any, you can try something like this:

    export enum CasingPattern {
      SNAKE,
      CAMEL,
    }
    
    // Helper types for transforming keys
    type SnakeToCamel<S extends string> = S extends `${infer T}_${infer U}`
      ? `${T}${Capitalize<U>}`
      : S;
    type CamelToSnake<S extends string> = S extends `${infer T}${Uppercase<infer U>}`
      ? `${T}_${Lowercase<U>}`
      : S;
    
    type TransformKeys<T, Pattern> = {
      [K in keyof T as Pattern extends CasingPattern.CAMEL ? SnakeToCamel<string & K> : CamelToSnake<string & K>]: T[K];
    };
    
    export function jsonCasingParser(jsonPayload: string, targetPattern: CasingPattern): string;
    export function jsonCasingParser<T extends Record<string, unknown>>(jsonPayload: T, targetPattern: CasingPattern.CAMEL): TransformKeys<T, CasingPattern.CAMEL>;
    export function jsonCasingParser<T extends Record<string, unknown>>(jsonPayload: T, targetPattern: CasingPattern.SNAKE): TransformKeys<T, CasingPattern.SNAKE>;
    
    // Function implementation
    export function jsonCasingParser(
      jsonPayload: unknown,
      targetPattern: CasingPattern,
    ): string | Record<string, unknown> {
      // ... rest of your implementation remains the same
    }
    

    Hope this helps…

    Login or Signup to reply.
  2. You will need a lot to make this happen. Thankfully someone already did most of the work here, but it’s not really everything we need: TypeScript convert generic object from snake to camel case. First, we are going to need some typescript utilities to convert strings from camel > snake and snake > camel.

    type SnakeToCamelString<S extends string | number | Symbol> =
      S extends `${infer T}_${infer U}` ?
      `${T}${Capitalize<SnakeToCamelString<U>>}` :
      S;
    
    type CamelToSnakeString<S extends string | number | Symbol> =
      S extends `${infer T}${infer U}` ?
      `${T extends Capitalize<T> ? "_" : ""}${Lowercase<T>}${CamelToSnakeString<U>}` :
      S;
    

    Then we are going to need some utilities to change the casing of all of the keys of an object. I modified the ones from the link above to be recursive:

    type SnakeToCamelObject<O extends object> = {
      [K in keyof O as SnakeToCamelString<K>]: O[K] extends object ? SnakeToCamelObject<O[K]> : O[K]
    }
    
    type CamelToSnakeObject<O extends object> = {
      [K in keyof O as CamelToSnakeString<K>]: O[K] extends object ? CamelToSnakeObject<O[K]> : O[K];
    }
    

    Then you will need to write overloads for your method to return the correct type based on the arguments. Overloading can be tricky at first – read more about it in the docs:

    function jsonCasingParser(jsonPayload: string, targetPattern: CasingPattern): string;
    function jsonCasingParser<T extends object>(jsonPayload: T, targetPattern: CasingPattern.SNAKE): CamelToSnakeObject<T>;
    function jsonCasingParser<T extends object>(jsonPayload: T, targetPattern: CasingPattern.CAMEL): SnakeToCamelObject<T>;
    function jsonCasingParser<T extends object>(jsonPayload: string | T, targetPattern: CasingPattern) {
      const isString = typeof jsonPayload === 'string';
      const stringified = isString ? jsonPayload : JSON.stringify(jsonPayload);
      
      // ... do the replacement stuff
    
      // This part is important. The logic here must match your overloads above.
      // TypeScript cannot validate this part for you... so be diligent.
      if (isString) return stringified;
      if (targetPattern === CasingPattern.SNAKE) return JSON.parse(stringified) as CamelToSnakeObject<T>;
      return JSON.parse(stringified) as SnakeToCamelObject<T>;
    }
    

    Finally, test your code and stare in awe at what TypeScript can do:

    const stringSnake = jsonCasingParser('{}', CasingPattern.SNAKE); // string
    const stringCamel = jsonCasingParser('{}', CasingPattern.CAMEL); // string
    
    const objectSnake = jsonCasingParser({ fooBar: 'bing', nested: { fooBar: 'bing' } }, CasingPattern.SNAKE);
    objectSnake.foo_bar; // YAY
    objectSnake.nested.foo_bar; // YAY
    
    const objectCamel = jsonCasingParser({ foo_bar: 'bing', nested: { foo_bar: 'bing' } }, CasingPattern.CAMEL);
    objectCamel.fooBar; // YAY
    objectCamel.nested.fooBar; // YAY
    

    Here’s a fully working playground

    UPDATE: Apparently you can overload arrow functions as described here: Typescript overload arrow functions. However, I was unable to get it to work. Here’s a playground

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search