🥏

[Typescript]オブジェクトのキーを🐪→🐍に変換する[Javascript]

2023/05/09に公開1

オブジェクトのキーをキャメルケースからスネークケースに変換するutil関数を作った後にts-case-convertで1行で解決出来ることを知った

TL;DL

インストール

npm install ts-case-convert

使い方

import { objectToSnake } from 'ts-case-convert';

objectToSnake(snakeCaseObject)

作成した関数

/** オブジェクトのキーをcamelCaseからsnake_caseに変換する */
export const convertKeysToSnakeCase = <T>(obj: T): T => {
  if (typeof obj !== 'object' || !obj) return obj;
  if (Array.isArray(obj)) {
    return obj.map((value: T[keyof T]) =>
      //objectがネストしている場合は再帰的にsnake_caseに変換する
      typeof value === 'object' ? convertKeysToSnakeCase<T>(value as T) : value
    ) as T;
  }
  return Object.keys(obj).reduce((acc: T, key: string) => {
    const snakeCaseKey = key.replace(
      /[A-Z]/g,
      (letter: string) => `_${letter.toLowerCase()}`
    );
    const value = obj[key as keyof T];
    acc[snakeCaseKey as keyof T] = //objectがネストしている場合は再帰的にsnake_caseに変換する
      typeof value === 'object' ? convertKeysToSnakeCase(value) : value;
    return acc;
  }, {} as T);
};

テスト

describe('テスト', () => {
  test('convertKeysToSnakeCase', () => {
    const input = {
      firstName: 'John',
      lastName: 'Doe',
      age: 30,
      address: {
        streetAddress: '1234 Main St',
        city: 'Anytown',
        state: 'CA',
        postalCode: '12345',
      },
      multiple: ['1', '2', { nestedMultiple: ['3', '4'] }],
    };

    const expectedOutput = {
      first_name: 'John',
      last_name: 'Doe',
      age: 30,
      address: {
        street_address: '1234 Main St',
        city: 'Anytown',
        state: 'CA',
        postal_code: '12345',
      },
      multiple: ['1', '2', { nested_multiple: ['3', '4'] }],
    };

    const actualOutput = convertKeysToSnakeCase(input);

    expect(actualOutput).toEqual(expectedOutput);
    expect({}).toEqual(convertKeysToSnakeCase({}));
  });
});

Discussion

nap5nap5

ぼくも少し試してみました。

推論結果がデモデータのパス式result.abilities[0].slotにおいてnumber|nullと推論してほしいところが、ts-case-convertライブラリの結果だとnumber|unknown[]と推論されているようでした。

想定しているユースケースとしてはWEB2DBないしはDB2WEBのようなマッピングレイヤだと考えています。本件のようなワークアラウンドを達成したい場合を別のアプローチでトライしてみました。

codesandboxだと正しく推論できないかもですが、手元では期待した型を得られているように見えました。

プロパティのキー名を変換したいデータの変換元と変換先の型をTypeHintするために型引数に渡して使う感じになります。

demo code.
https://codesandbox.io/p/sandbox/nameless-dawn-1lto4i?file=%2Fsrc%2Fcool.ts%3A1%2C1

import { test, expect } from "vitest";

import { mappingForDB, mappingForWEB } from ".";
import { CamelCasedPropertiesDeep, SnakeCasedPropertiesDeep } from "type-fest";

test("mappingForWEB", () => {
  const data = {
    abilities: [
      {
        ability: {
          name: "poison-point",
          url: "https://pokeapi.co/api/v2/ability/38/",
        },
        is_hidden: false,
        slot: null,
      },
      {
        ability: {
          name: "hustle",
          url: "https://pokeapi.co/api/v2/ability/55/",
        },
        is_hidden: true,
        slot: 3,
      },
    ],
    base_experience: 128,
    forms: [
      { name: "nidorino", url: "https://pokeapi.co/api/v2/pokemon-form/33/" },
    ],
    game_indices: [
      {
        game_index: 167,
        version: {
          name: "red",
          url: "https://pokeapi.co/api/v2/version/1/",
        },
      },
      {
        game_index: 167,
        version: {
          name: "blue",
          url: "https://pokeapi.co/api/v2/version/2/",
        },
      },
    ],
    height: 9,
    held_items: [],
    id: 33,
    is_default: true,
  };

  type Demo = typeof data;
  type DemoForWEB = CamelCasedPropertiesDeep<Demo>;
  type DemoForDB = SnakeCasedPropertiesDeep<Demo>;
  const result = mappingForWEB<DemoForDB, DemoForWEB>(data);
  expect(result.abilities[0].slot).toStrictEqual(null);
});

test("mappingForDB", () => {
  const data = {
    abilities: [
      {
        ability: {
          name: "poison-point",
          url: "https://pokeapi.co/api/v2/ability/38/",
        },
        isHidden: false,
        slot: null,
      },
      {
        ability: {
          name: "hustle",
          url: "https://pokeapi.co/api/v2/ability/55/",
        },
        isHidden: true,
        slot: 3,
      },
    ],
    baseExperience: 128,
    forms: [
      { name: "nidorino", url: "https://pokeapi.co/api/v2/pokemon-form/33/" },
    ],
    gameIndices: [
      {
        gameIndex: 167,
        version: { name: "red", url: "https://pokeapi.co/api/v2/version/1/" },
      },
      {
        gameIndex: 167,
        version: { name: "blue", url: "https://pokeapi.co/api/v2/version/2/" },
      },
    ],
    height: 9,
    heldItems: [],
    id: 33,
    isDefault: true,
  };
  type Demo = typeof data;
  type DemoForWEB = CamelCasedPropertiesDeep<Demo>;
  type DemoForDB = SnakeCasedPropertiesDeep<Demo>;
  const result = mappingForDB<DemoForWEB, DemoForDB>(data);
  expect(result.abilities[0].is_hidden).toStrictEqual(false);
});

簡単ですが、以上です。