normalizrの型定義を書いた
Normalizrの型定義を(制約付きで)書いた
normalizrのhelper型です。こうなってくれているといいなぁと思って書きました。
読むのめんどくさいよ〜って人はサンプルを用意したのでcodesandboxか手元の環境でガチャガチャいじってみてください https://github.com/naporin0624/normalizr_ts
Normalizrというライブラリについて
normalizrというライブラリがあります。これはobjectをユーザが定義した構造(normalizrはこれをschemaとよんでいる)に従って分解できるライブラリでRedux公式ドキュメントに使うといいよって書かれてたりします。Normalizing State Shape | Redux
何ができるか
GitHubに大まかな挙動・APIドキュメントが存在しているので食わせるデータと結果だけを書きます。
食わせるデータ
{ "id": "123", "author": { "id": "1", "name": "Paul" }, "title": "My awesome blog post", "comments": [ { "id": "324", "commenter": { "id": "2", "name": "Nicole" } } ] }
ユーザがschema
を定義します。
import { normalize, schema } from 'normalizr'; // Define a users schema const user = new schema.Entity('users'); // Define your comments schema const comment = new schema.Entity('comments', { commenter: user }); // Define your article const article = new schema.Entity('articles', { author: user, comments: [comment] }); const normalizedData = normalize(originalData, article);
normalizedData
の結果がこうなります。
{ result: "123", entities: { "articles": { "123": { id: "123", author: "1", title: "My awesome blog post", comments: [ "324" ] } }, "users": { "1": { "id": "1", "name": "Paul" }, "2": { "id": "2", "name": "Nicole" } }, "comments": { "324": { id: "324", "commenter": "2" } } } }
やってくるjsonをユーザが定義したschema
ごとに分解してentities
配下に置かれます。result
をnormalizr
にあるdenormalize
に食わせることでもとのjsonを得ることができるというものです。ちなみにdenormalize
は次のように記述します。
import { denormalize } from 'normalizr'; const denormalizedData = denormalize(result, article, entities /* jsonのentitiesを入れる */ );
かなり柔軟にschemaは意義できます。normalizr/api.md at master · paularmstrong/normalizr · GitHub
TypeScriptの型定義を見てみる
normalizr/index.d.ts at master · paularmstrong/normalizr · GitHub
export function normalize<T = any, E = { [key:string]: { [key:string]: T } | undefined}, R = any>( data: any, schema: Schema<T> ): NormalizedSchema<E, R>; export function denormalize( input: any, schema: Schema, entities: any ): any;
かなりanyだらけになっていてnormalize
するとき投入したdata
に対してどのschema
がマッチするのかわからないです。またdenormalize
も同じで投入するinput
に対してどのschema
を入れればよいのかわからない状態になっています。かなり柔軟にschemaを定義できるようにした代償なのかなと思っている。(僕も柔軟なschemaを維持したまま型定義を書こうとして辛くなってやめた過去もあります。)なので今回はschemaの作り方に制約をもたせて型定義を書いていきます。
目指すところ
- entity型に含まれるentityを探し出して
schema.Entity
の第2引数に必要なschema.Entity
の組が推論される normalize
の第1引数に入れた型からほしいschema.Entity
を推論するdenormalize
のReturnTypeを推論する
normalizrのschemaに制約をもたせる
A, B, Cはそれぞれの対応関係を型定義で表したものです。これからこの型定義のことをentityといいます。
Cの型を持つjsonが飛んできてnormalize
に食わせるとA, B, Cをentities
配下に持ち, Cのidをresultに持つオブジェクトが返ってくるようなイメージです。
型定義を作るにあたってentityに制約をもたせます。 1. entityは必ずidを持つ 2. entityの持つプロパティにはentity同士のunion型を許容しない - C型のfのような形は許可しません - c, d, eのような形は問題ないです 3. entityの持つプロパティにはentityを含むPropertySignatureを許容しない - C型のgのような形は許可しません
type A = { id: string | number; // required hoge: string; huga: number; } type B = { id: string | number; // required cat: string[]; dog: string[]; } type C = { id: string | number; // required a: number; // ok b: number | string; // ok c: A; // ok d: A[]; // ok e: A | null; // ok f: A | B; // ng g: { a: A } // ng }
schemaに制約をもたせてnormalizrの型を書いてみる
実装するにあたりこの型はentityであるということを示すためにBaseEntity
という型をつくりentityを作るときはこれをマージすることにしました。
type IUser = { name: string; address: string; phoneNumber: number; } & BaseEntity;
構造を定義するschemaの型定義をかく
RelatedEntities
でGenericで渡されてきたentityの中で使われているentityを探し出します。探し出されたrelatedEntities
がそのままnew schema.Entity
の第2引数に渡るのでentityの型からnormalizr
に渡すschemaを決定することができます。
import { schema } from "normalizr"; export const createEntity = <T extends BaseEntity>( entityName: string, relatedEntities: RelatedEntities<T>, ): schema.Entity<T> => new schema.Entity<T>(entityName, relatedEntities);
RelatedEntities型
RelatedEntities型はGenericを受け入れます。Find
型により、受け入れたTを1層だけmapped typeで走破してBaseEntity
が持つkeyを両方持っているものをentityとみなして新たに型を作り上げていきます。BaseEntity
は型情報としては持っているが値としては入れられることもないし、認識することもない_$entity
というプロパティを持っています。これのおかげでid
だけをプロパティとしてもっていてもentityとして検知されないようにしています。つまり、BaseEntity
をマージしたものだけをentityとしてすく上げることができるようになります。
すくい上げられたentityだけを含む型に対してnormalizr
のschema.Entity
型を付与していきます。
import { schema } from "normalizr"; type BaseEntity = { id: string; _$entity?: unknown; }; type NestArray<T> = T | Array<NestArray<T>>; type Flatten<T extends NestArray<unknown>> = T extends NestArray<infer I> ? I : never; type BaseType<T> = Exclude<Flatten<T>, null | undefined>; type FindKeys<T> = { [K in keyof T]: keyof BaseEntity extends keyof BaseType<T[K]> ? K : never }[keyof T]; type Schema<T> = T extends Array<infer U> ? [Schema<U>] : schema.Entity<T>; type DictSchema<T extends { [key: string]: unknown }> = { [K in keyof T]: Schema<Exclude<T[K], undefined | null>> }; type Find<T> = { [K in FindKeys<T>]: T[K] }; type RelatedEntities<T extends { [key: string]: unknown }> = DictSchema<Find<T>>;
normalizeの型を書く
normalize
の型は割と簡単です。受け入れるTに対応したschema
を待ち受ければよいだけです。
返り値はresultとentitiesがありますが、entitiesは今回かんたん化のためにユーザが定義したentityをimportしてEntities
という型を作っています。(ここも推論できるのがより良いですが、実現できなかったのでこのようにしました:sob:)
Entities
のなかにあるNormalizedEntity
型はGenericで渡されるentityの中からentityを探し出してstringまたは, stringの配列にしてくれるものです。entityを含むentityをnormalize
するとentityがあった部分には他のentityへの参照であるidが格納されているからです。(normalizedData
を見るとわかると思います)
import { schema, normalize, NormalizedSchema } from "normalizr"; type Schema<T> = T extends Array<infer U> ? [Schema<U>] : schema.Entity<T>; type ArrayToString<T> = T extends Array<infer U> ? Array<ArrayToString<U>> : string; type NormalizedEntity<T extends { [key: string]: unknown }> = { [K in keyof T]: K extends keyof Find<T> ? ArrayToString<T[K]> : T[K]; }; type Entities = Partial<{ // 定義したentity型をimportしてnormalizeされたときに排出されるentitiesの型とする users: { [key: string]: NormalizedEntity<IUser> }; groups: { [key: string]: NormalizedEntity<IGroup> }; addresses: { [key: string]: NormalizedEntity<IAddress> }; }> type Result<T> = T extends Array<infer U> ? Array<Result<U>> : string; export const normalizer = <T>(data: T, schema: Schema<T>): NormalizedSchema<Entities, Result<T>> => normalize<T, Entities, Result<T>>(data, schema);
denormalizeの型を書く
denoramlize
はdata
(単一のidもしくはidの配列)とschema
(data
が分解される前の構造情報)とentities
を渡せばよいです。
data
が単一ならschema
も単一で, data
が配列ならschema
も配列で与えるように型を書いていきます。data
からはdenormalize
のReturnTypeを推論することはできないので, 渡されたschema
からentityの型をGenericを用いて取り出します。Genericで取り出されたentityの型S
を渡されたdata
の型に合わせて整形し直します。
denoamlize
したときに第3引数のentities
に必要なデータがないときdata
が単一ならundefined
, 配列なら[]
が返されるのでDenormalized
型を使ってそれを表現します。
import { schema, denormalize } from "normalizr"; type NestArray<T> = T | Array<NestArray<T>>; type Entity<D, T> = D extends Array<infer U> ? [Entity<U, T>] : schema.Entity<T>; type Denormalized<D, S> = D extends Array<infer U> ? Array<Denormalized<U, S>> : S | undefined; export const denormalizer = <D extends NestArray<string>, S, T extends Entities>( data: D, schema: Entity<D, S>, entities: T, ): Denormalized<D, S> => denormalize(data, schema, entities);
使ってみる
こちらにサンプルコードをpushしたのでcheckoutすればすぐ使えます。GitHub - naporin0624/normalizr_ts codesandboxにも上げましたempty-glade-xxys6 - CodeSandbox
createEntity
src/entities/group.tsをみてください
import { IUser, userEntity } from "./user"; import { createEntity } from "../normalizer"; import { BaseEntity } from "../types"; export type IGroup = { users: IUser[]; } & BaseEntity; export const groupEntity = createEntity<IGroup>("groups", { users: [userEntity] });
IUser
がentityなのですがcreateEntity
の第2引数からusersを消すと型エラーになります。
またsrc/entities/user.tsはIAddress
だけがentityでありcomplex, keysが持つ型にはcreateEntity
が反応してないことがわかります。
BaseEntityをマージしてないとentityとして認識されないためこの型がentityなのかそうでないのかがしっかり分類できています。
normalizer
与えられたgroup
オブジェクトからほしいschema
を導き出せています。result
の型も渡されたgroup
オブジェクトは単一なのでstringが返ってきています
denoramlzer
result
は単一のstring, schemaも単一のgroupEntityなので返り値はIGroup | undefined
型になっています。
こちらは配列の場合です。同様にうまくいっています。
さいごに
僕はReduxを使う際にnormalizrを多用するのでこのようなヘルパー型を作って楽に開発できるようにしています。entityの作り方に制約をもたせていますが、今のところ特に苦しい場面に出会ってません。(複雑な構造に出会ってないだけかも) なので結構良さそうじゃないかな〜と思っています。できるならライブラリにしたいけど型定義だけのライブラリって作る意味あるんだろうか……。normalizrの機能もかなり制限しちゃってるし…
次はこれをどうReduxに使っているのかを紹介できればなと思います。最後まで見てくださった方ありがとうございました!!
もっとこうしたらいいよという提案はいつでも受け付けています!!