TypeScriptでスネークケース🐍←→キャメルケース🐪に変換する型を定義する。理解する。
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パッケージも作っておいたのでご利用ください。(⭐を何卒...🙏)
そして、本記事ではこの型定義がどういう仕組みで、🐍←→🐪ケースの変換を行なっているのかステップバイステップで解説していきます。この記事を読み終わる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させることができます。
先ほどのIsString
にnumber
型、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 | undefined
は null | 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
inferはConditionalTypeの評価式内の型を参照できる機能です。
以下の関数の戻り値の型を取り出す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_id
をuserId
のように変換する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
// = {'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
先ほどと同様にまずは単体の文字列userId
→user_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