なぽろぐ

気ままに感じたことを記事にまとめます。

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配下に置かれます。resultnormalizrにある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だけを含む型に対してnormalizrschema.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の型を書く

denoramlizedata(単一の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を消すと型エラーになります。 Image from Gyazo

配列になってないときも型エラーを吐いてくれます。 Image from Gyazo

またsrc/entities/user.tsはIAddressだけがentityでありcomplex, keysが持つ型にはcreateEntityが反応してないことがわかります。 BaseEntityをマージしてないとentityとして認識されないためこの型がentityなのかそうでないのかがしっかり分類できています。 f:id:Naporitan:20201005010616p:plain

normalizer

与えられたgroupオブジェクトからほしいschemaを導き出せています。resultの型も渡されたgroupオブジェクトは単一なのでstringが返ってきています f:id:Naporitan:20201005010605p:plain

denoramlzer

resultは単一のstring, schemaも単一のgroupEntityなので返り値はIGroup | undefined型になっています。 f:id:Naporitan:20201005010554p:plain

こちらは配列の場合です。同様にうまくいっています。 f:id:Naporitan:20201005010539p:plain

さいごに

僕はReduxを使う際にnormalizrを多用するのでこのようなヘルパー型を作って楽に開発できるようにしています。entityの作り方に制約をもたせていますが、今のところ特に苦しい場面に出会ってません。(複雑な構造に出会ってないだけかも) なので結構良さそうじゃないかな〜と思っています。できるならライブラリにしたいけど型定義だけのライブラリって作る意味あるんだろうか……。normalizrの機能もかなり制限しちゃってるし…

次はこれをどうReduxに使っているのかを紹介できればなと思います。最後まで見てくださった方ありがとうございました!!

もっとこうしたらいいよという提案はいつでも受け付けています!!