🐍

TypeScriptでスネークケース🐍←→キャメルケース🐪に変換する型を定義する。理解する。

2022/02/14に公開

TL;DR

🐍→🐪に変換する型がこう!

type SnakeToCamelCase<T extends string> = 
T extends `${infer R}_${infer U}` ? `${R}${Capitalize<SnakeToCamelCase<U>>}` : T

type SnakeToCamel<T extends object> = {
  [K in keyof T as `${SnakeToCamelCase<string & K>}`]: T[K] extends object ? SnakeToCamel<T[K]> : T[K]
}

type SnakeUser = {
  user_name: string,
  is_married: boolean,
}
type CamelUser = SnakeToCamel<SnakeUser>
// type CamelUser = {
//  userName: string;
//  isMarried: boolean;
// }

🐪→🐍に変換する型がこう!

type CamelToSnakeCase<S extends string> =
  S extends `${infer T}${infer U}` ?
  `${T extends Capitalize<T> ? "_" : ""}${Lowercase<T>}${CamelToSnakeCase<U>}` : S

type CamelToSnake<T extends object> = {
  [K in keyof T as `${CamelToSnakeCase<string & K>}`]: T[K] extends object ? CamelToSnake<T[K]> : T[K]
}

type CamelUser = {
  userName: string,
  isMarried: boolean
}
type SnakeUser = CamelToSnake<CamelUser>
// type SnakeUser = {
//    user_name: string;
//    is_married: boolean;
// }

です。

何やってるかよくわかりませんよね笑?自分も初めは全くわかりませんでした。

めちゃくちゃ急いでいる方は上記の型定義をコピペするか、こちらに型だけを定義したnpmパッケージも作っておいたのでご利用ください。(⭐を何卒...🙏)

https://github.com/kazuooooo/snake-camel-types

そして、本記事ではこの型定義がどういう仕組みで、🐍←→🐪ケースの変換を行なっているのかステップバイステップで解説していきます。この記事を読み終わる13分後にはあなたも立派な型パズラーになっていることでしょう!

ではいってみましょう!


前提知識

今回の🐍←→🐪ケースの型定義を理解するのに、応用的な型の知識がいくつか必要になるので、まずはそれらについて解説します。
(これだけでも長いので知っているものはじゃんじゃん飛ばしてください🙏)

TemplateLiteralType

TypeScript4.1から導入されたTemplateLiteralTypeは型定義でもTemplateLiteralsのように ${型名} の記法で文字列の型定義を埋め込みできる機能です。

type World = "world";
type Greeting = `hello ${World}`;
// hello world

TemplateLiteralTypeの使い所として便利なのが、特定のルールを持つ文字列に対して型レベルで制限をかけられるところです。

例えばユーザーIDにid_ のようなプレフィックスを入れたい場合は以下のように書くことができます。

// id_がプレフィックスとして入るように型レベルで強制することができる。
type UserId = `id_${string}`

const correctId: UserId = 'id_abcdefg' // OK
// Type '"abcdefg"' is not assignable to type '`id_${string}`'.ts(2322)
const invalidId: UserId = 'abcdefg'

ConditionalType

概要

ConditionalType型の三項演算子と考えるとスムーズに理解できます。
記法は以下のような形です

X extends Y ? B : C

通常の三項演算子と異なるのは条件式の部分をX extends Y と書くことです。

型Xが型Yを

  • extendsできる場合 → B
  • できない場合 → C
    を返すようにTypeScriptのコンパイラーに推論させることができます。

具体例を見てみましょう。

型引数に渡された型がstringの場合’this is string’ 型を返す型を定義したのが以下です。

type IsString<T> = T extends string ? 'this is string' : never
type StringType = IsString<'hoge'> // 'this is string'
type NumberType = IsString<3>      // never

ConditionalTypeをChainさせる

さらにこのConditionalTypeはChainさせることができます。

先ほどのIsStringnumber型、boolean型を追加してThisIsXTypeという型を定義してみます。

type ThisIsXType<T> =
  T extends string ? 'this is string' :
  T extends number ? 'this is number' :
  T extends boolean ? 'this is boolean' :
  never

type StringType = ThisIsXType<'hoge'> // this is string
type NumberType = ThisIsXType<3>      // this is number
type BooleanType = ThisIsXType<true>  // this is boolean
type OtherType = ThisIsXType<[]>      // never

Conditional typesとUnion型を組み合わせた場合のループ評価

ConditionalTypesとUnion型を組み合わせた以下の型定義について考えてみます。

type NonNullable<T> = T extends null | undefined ? never : T

type Result = NonNullable<string | null | undefined> // string

こちら一見すると不思議な結果になっていることにお気づきでしょうか。
型引数に渡されたstring | null | undefinednull | undefined をextendsできないのでstring | null | undefined がそのまま返りそうですががなぜか演算結果はstringになっています。

NonNullable<string | null | undefined> 
// = string | null | undefined extends null | undefined ? never : string | null | undefined
// = string | null | undefined
// となるのでは🤔?

実はConditionalTypeのextendsにUnion型を渡した場合、Union型内の型が1つずつ別々にループ評価され、それぞれの結果を再度合算してUnion型とします。

つまり先ほどの例は内部的に以下のように推論されているわけです。

// string | null | undefinedに対してそれぞれ評価される
NonNullable<string | null | undefined> 

// stringを評価
type stringLoop = string extends null | undefined ? never : string
// => string

// nullを評価
type nullLoop = null extends null | undefined ? never : null
// => never

// undefinedを評価
type undefinedLoop = undefined extends null | undefined ? never : undefined
// => never

// 最後に評価の結果をunion
type ReturnUnion = stringLoop | nullLoop | undefinedLoop
// => string | never | never
// => string

infer

inferConditionalTypeの評価式内の型を参照できる機能です。

以下の関数の戻り値の型を取り出すFunctionReturnTypeをみてみます。

type FunctionReturnType<T extends (...args: any) => any> =
  T extends (...args: any) => infer R ? R : any

type StringReturnFunc = FunctionReturnType<() => string> // string

ポイントはinfer R が使われているこの部分です。

T extends (...args: any) => infer R

この部分に対してStringReturnFunc型では() => string が渡されているので、infer Rによってstringが参照できるわけです。

T extends (...args: any) => infer R
//                   ()  => string
//                          R = string

Recursive Conditional Types

TypeScript4.1からRecursiveConditionalTypesという再起的にConditionalTypeを呼び出せる機能が導入されました。

例えばこの機能を使って多次元配列を1次元配列に戻す関数の型は以下のように書くことができます。

type ElementType<T> =
    T extends ReadonlyArray<infer U> ? ElementType<U> : T;

function deepFlatten<T extends readonly unknown[]>(x: T): ElementType<T>[] {}

// 全てnumber[]が返る
deepFlatten([1, 2, 3]);
deepFlatten([[1], [2, 3]]);
deepFlatten([[1], [[2]], [[[3]]]]);

配列の中身に対して再起的にElementTypeを呼び出して最後にUnionをとることで戻り値は全てnumber[]の1次元配列となります。

Key Remapping in MappedType

TypeScript4.1から導入されたKey Remapping in MappedTypeオブジェクトのキーをリマップすることができる機能です。

type User = {
  foo: string,
  bar: string,
  piyo: number
}

type Setters<T> = {
  [K in keyof T as `set${Capitalize<string & K>}`]: (K: T[K]) => void
}

type UserSetters = Setters<User>

// type UserSetters = {
//  setFoo: (K: string) => void;
//  setBar: (K: string) => void;
//  setPiyo: (K: number) => void;
// }

ポイントは

as `set${Capitalize<string & K>}`

の部分で、as {リマップしたいキー} と書くことで、キーを別のキーにリマップできます。

以上で前提となる知識の説明はおしまいです。
(盛りだくさんだったと思うので、この辺りで一度休憩しましょう🥤)


SnakeToCamel(🐍→🐪)

ではいよいよここまで説明してきた知識をフル活用して、本題のスネークケース🐍→キャメルケース🐪に変換する型を定義してみましょう!

SnakeToCamelCaseを定義する

まずはスネークケース🐍の文字列user_iduserId のように変換するSnakeToCamelCaseを定義していきます。

type SnakeToCamelCase<T extends string> = 
T extends `${infer R}_${infer U}` ? `${R}${Capitalize<SnakeToCamelCase<U>>}` : T

type CamelUserId = SnakeToCamelCase<'user_id'>
// => userId

user_idがどのようにuserIdに変換されるか1つずつみてみましょう。

まず型引数Tに渡された’user_id’ はinferを使ってuserとidに分解されます。

SnakeToCamelCase<'user_id'>
// = T extends `${infer R}_${infer U}`
// = 'user_id' extends `'user'_'id'`

そしてConditionalTypeの評価はtrueの条件に入るので以下のようになります。

// = 'user_id' extends `'user'_'id'` ? `${'user'}${Capitalize<SnakeToCamelCase<'id'>>}` : T
// = {'user'}{Capitalize<SnakeToCamelCase<'id'>>}

ここでSnakeToCamelCaseが再起的に’id’に対して呼び出されますが、idはextends {infer R}_{infer U}`` を満たさないので'id'がそのまま返ります。

// = {'user'}{Capitalize<SnakeToCamelCase<'id'>>}
// = {'user'}{Capitalize<'id' extends `${infer R}_${infer U}` ? `${R}${Capitalize<SnakeToCamelCase<U>>}` : 'id'>
// = {'user'}{Capitalize<'id'>}

最後に{user}{Capitalize<'id'>} が評価されて、userIdとなるわけです🙌

`${R}${Capitalize<SnakeToCamelCase<U>>}`
// = {user}{Capitalize<SnakeToCamelCase<id>>}
// = {user}{Capitalize<'id' extends `${infer R}_${infer U}` ? `${R}${Capitalize<SnakeToCamelCase<U>>}` : 'id'>
// = {user}{Capitalize<'id'>}
// = 'userId'

ちなみに3語以上の単語で呼び出した場合は再帰呼び出しによって処理されます。

type SnakeUserTelNumbeer = 'user_tel_number'
SnakeToCamelCase<SnakeUserTelNumbeer>
// = {user}{Capitalize<SnakeToCamelCase<id>>}
// = {user}{Tel}{Capitalize<SnakeToCamelCase<number>>}
// = userTelNumber

SnakeToCamelを定義する

続いて前項で定義したSnakeToCamelCaseを使って、オブジェクトをキャメルケースに変換できる型を定義します。

type SnakeToCamel<T extends object> = {
  [K in keyof T as `${SnakeToCamelCase<string & K>}`]: T[K]
}

type SnakeUser = {
  user_id: string,
  user_tel_number: string
}

type CamelUser = SnakeToCamel<SnakeUser>
// type CamelUser = {
//   userId: string;
//   userTelNumber: string;
// }

ポイントはこの部分です。

Kにスネークーケースで入ってくるプロパティのキーを前述のKey Remapping in MappedTypeを使って、キャメルケースに変換しています。

[K in keyof T as `${SnakeToCamelCase<string & K>}`]
// user_id => userId
// user_tel_number => userTelNumber

ネストに対応する

現時点の定義ではオブジェクトがネストした場合に対応できていないので、型定義を修正します。

type SnakeToCamel<T extends object> = {
  [K in keyof T as `${SnakeToCamelCase<string & K>}`]: T[K] extends object ? SnakeToCamel<T[K]> : T[K]
}

値側に以下のようなConditional Typeを定義することで、オブジェクトの場合はSnakeToCamelが再帰的に呼び出されるようになり、ネストされたオブジェクトのキーも全てキャメルケースに変換されるようになります。

// T[K]がオブジェクトの場合SnakeToCamelを再帰的に呼び出されます。
T[K] extends object ? SnakeToCamel<T[K]> : T[K]

CamelToSnake (🐪→🐍)

今度は逆、キャメルケースをスネークケースに変換する型を定義していきます。

CamelToSnakeCase

先ほどと同様にまずは単体の文字列userIduser_idのように変換する型を定義します。

type CamelToSnakeCase<S extends string> =
  S extends `${infer T}${infer U}` ?
  `${T extends Capitalize<T> ? "_" : ""}${Lowercase<T>}${CamelToSnakeCase<U>}` : S

type snakeUserId = CamelToSnakeCase<'userId'> // user_id

先ほどよりもなんだか複雑ですね。

ポイントはまずinferの箇所です。スネークケースを変換する場合は_で区切られるのでわかりやすかったですが、userIdのような文字列がきた場合にはどのように分割されるのでしょうか?

'userId' extends `${infer T}${infer U}` // userIdを渡すとT、Uにはそれぞれ何が入る?

実はこれはTにはu一文字だけが入ります。

'userId' extends `${infer T}${infer U}`
                  //{u}      {serId} に分かれる

こちらを念頭においた上で定義された型をみてみましょう。

例えばuserId のキーが渡された場合ConditionalTypeは以下のようになります。

S extends `${infer T}${infer U}`
// = 'userId' extends `${'u'}${'serId'}`

そしてこのConditionalTypeは常にtrueが返るので、前半の式が評価されます。

‘u’は小文字なのでConditionalTypeはfalseとなり、空文字に後ろにLowercase<’u’>がつくので結局この部分は同じ’u’になります。そしてこの後ろに’serId’が再帰的にCamelToSnakeCaseを呼び出します。

`${'u' extends Capitalize<'u'> ? "_" : ""}${Lowercase<'u'>}${CamelToSnakeCase<'serId'>}`
// = 'u'${CamelToSnakeCase<'serId'>

この処理を繰り返していくと、小文字であるうちはただ何も変化せずに後ろの文字に流れていくだけです。

`${'u' extends Capitalize<'u'> ? "_" : ""}${Lowercase<'u'>}${CamelToSnakeCase<'serId'>}`
// = 'u'${CamelToSnakeCase<'serId'>
// = 'us'${CamelToSnakeCase<'erId'>
// = 'use'${CamelToSnakeCase<'rId'>
// = 'user'${CamelToSnakeCase<'Id'>

大文字のIに当たった時に変化が起きます。

'I' extends Capitalize<T>を満たすのでIはアンダーバー付きの_i に変換されます。このように大文字を見つけたら_小文字に変換しているわけです。

`${'u' extends Capitalize<'u'> ? "_" : ""}${Lowercase<'u'>}${CamelToSnakeCase<'serId'>}`
// = 'u'${CamelToSnakeCase<'serId'>
// = 'us'${CamelToSnakeCase<'erId'>
// = 'use'${CamelToSnakeCase<'rId'>
// = 'user'${CamelToSnakeCase<'Id'>
// = 'user'${'I' extends Capitalize<T> ? "_" : ""}${Lowercase<'I'>}
//    ↓Iが_iに変換された🐍
// = 'user_i'${CamelToSnakeCase<'d'>
// = 'user_id'

CamelToSnakeを定義する

では最後に前項で定義したCamelToSnakeCase を使ってオブジェクトをCamelケースからSnakeケースに変換してみます。

今回は内容がSnakeToCamelと重複するので、ネストにもまとめて対応します。

type CamelToSnake<T extends object> = {
  [K in keyof T as `${CamelToSnakeCase<string & K>}`]: 
		T[K] extends object ? CamelToSnake<T[K]> : T[K]
}

仕組みとしてはSnakeToCamelの時と同じです。

キー側では各プロパティをCamelToSnakeCaseを使ってスネークケースに変換し、値側ではオブジェクトの場合は再帰呼び出し、オブジェクトでない場合は値をそのまま返しています。

// (キーをキャメルケースからスネークケースに変換) : オブジェクトの場合は再帰、そうでない場合は値を入れて終了
[K in keyof T as `${CamelToSnakeCase<string & K>}`]: T[K] extends object ? CamelToSnake<T[K]> : T[K]

以上です!

型パズル難しいですね😅

ただ今回記事の中でご紹介した、少し応用的な型定義の知識を知っているだけで複雑な型定義が読めるようになったり、自身でも応用的な型が定義できるようになってくるはずです。

時間をかけて勉強して損はないところだと思うので、毛嫌いせずにどんどんやっていきたいですね💪


参考リンク
Understanding infer in TypeScript
TypeScript convert generic object from snake to camel case**
Inferring types in a conditional type

Discussion