TypeScript でプレーンなオブジェクトを unique symbol で区別させる

TypeScript で number 型を unique symbol で区別させる + ほのかな注意点 - o296.com では互換性のあるクラスを別のクラスとしてコンパイラーに区別させる方法を紹介しました。

同じ仕組みでプレーンなオブジェクトも区別できるのではないかとチャレンジしました。

定義する

declare const NormalUserType: unique symbol;
declare const AdminUserType: unique symbol;

type NormalUser = {
  [NormalUserType]: void
  no: number
  name: string
}

type AdminUser = {
  [AdminUserType]: void
  no: number
  name: string
}

function saveAdminUser(u: AdminUser): void{}
function saveNormalUser(u: NormalUser): void{}

const normalUser = {} as NormalUser

saveNormalUser(normalUser)
saveAdminUser(normalUser)  // Argument of type 'NormalUser' is not assignable to parameter of type 'AdminUser'.

問題点

ただしこのオブジェクトを作成する関数を素直に実装すると、コンパイルはできますがランタイムエラーになります。unique symbol は宣言のみだからです。

TypeScript 側

function create(params: { no: number, name: string }): NormalUser {
  return {
    [NormalUserType]: undefined,
    ...params,
  }
}

create({ name: 'mmmpa', no: 1 })

コンパイル済み JavaScript

function create(params) {
    return Object.assign({ [NormalUserType]: undefined }, params);
}
create({ name: 'mmmpa', no: 1 }); // Uncaught ReferenceError: NormalUserType is not defined

単純な解決策

シンボルを宣言ではなく定義にした場合このエラーは発生しませんが、コードに痕跡が残ってしまい嬉しくありません。

コードは省略します。

本来必要なフィールドを満たしている型を unique symbol フィールドを持った型と見なすマーキング関数をつくる

そこで

  1. unique symbol フィールドを除いたフィールドを満たす型を定義し、
  2. その新たな型を満たすデータは unique symbol フィールドを持った型にアサーションする

仕組みを用意して、クラスのときと同じように痕跡を残さないようにしたいと思います。

unique symbol フィールドを除いたフィールドを満たす型を定義

Pick は指定の型に含まれるフィールドのみを取りだした型にするビルトインのジェネリック型です。

ユニオン型中の never はないことになる性質を用い、元の型のフィールドから symbol 以外の key を抽出して Pick に渡して必要なフィールドのみを持つ型を作成します。

type RequiredFields<
  T,
  K extends keyof T = keyof T
> = Pick<T, K extends symbol ? never : K>

得られる型

type NormalUserParams = RequiredFields<NormalUser>
// type NormalUserParams = {
//     no: number;
//     name: string;
// }

詰まった点メモ

keyof T は型引数側で定義する必要があります。

type 定義側の extends 両辺で定義した場合、それぞれ別の型とみなされるため常に全ての keyof が返ってしまうためです。

マーキング関数作成関数を書く

型ごとに手動で FooParams を定義するのは面倒ですから、以上で用意した型を使って マーキング関数作成関数を書きます。

function marker<T>() {
  return function mark(params: RequiredFields<T>): T {
    return params as unknown as T
  }
}

返り値には unique symbol フィールドが欠落しているので、params をそのまま返すコードではコンパイルが通りません。前述までの型が正しければ返り値が必要なフィールドを満たしていることは明確なので、型アサーションで型を与えます。

このような型アサーションが発生する状況下で元のデータの型を手で定義するとそこがエラーの侵入口となるので、この局面はまさしく TypeScript の動的定義の出番と言えるでしょう。

マーキング結果

必要なコンパイルエラーは得られ、無用なフィールドも入り込まず、良好な結果となりました。

const markAsNormalUser = marker<NormalUser>()
// const markAsNormalUser: (params: Pick<NormalUser, "no" | "name">) => NormalUser

// ok
markAsNormalUser({ no: 1, name: 'mmmpa' })

// 以下はコンパイルエラー
markAsNormalUser({ no: 1 })
markAsNormalUser({ no: 1, name: 'mmmpa', password: '' })
markAsNormalUser({ no: 1, name: 'mmmpa', [NormalUserType]: '' })

ファクトリー関数作成関数を書く

実際のところ一部のフィールドのみから全体のデータを作成することは少なくありません。その局面で一度全体のデータを作成する関数を通してからマーカーを通すのは面倒です。

そこで必要なフィールドを満たす返り値を返すファクトリー関数を使って元の型をダイレクトに返す関数を作成する関数を定義します。

定義

function factoryFactory<
  T,
  Fn extends (...p: any) => RequiredFields<T>,
>(_: T, factory: Fn): (...p: Parameters<Fn>) => T {
  return function (...p: any) {
    return factory(...p) as T
  }
}

詰まった点メモ (未解決)

第一引数に元の型を持つダミーデータをとっていることに注目してください。型引数に任意の型を与える場合全ての型引数に型を与えるかデフォルト値 (T = Foo) を与える必要があります。

デフォルト値では引数に取るファクトリー関数の引数が推論できないので type を渡す必要がありますが、記述が冗長になるので避けたいと思いました。そこでメソッドのダミー引数から型を取得できるようにしています。

(スマートな解決方法があればご教示いただければ幸いです @mmmpa)

使用

無事ファクトリーが得られました。また不正なファクトリー関数ではコンパイルエラーを得られます。

(ただしこれでは前記のマーカーと違って余計なフィールドは通ってしまいます。ここらへんガチガチにする方法をご存知であればご一報いただけると幸いです @mmmpa)

const NU = {} as NormalUser

const f1 = factoryFactory(NU, (n: { name: string, no: number }) => {
  return {
    name: n.name,
    no: n.no,
  }
})
// const f1: (n: {
//     name: string;
//     no: number;
// }) => NormalUser

const f2 = factoryFactory(NU, (name: string, no: number) => {
  return {
    name,
    no,
    extra: '', // 通ってしまう
  }
})
// const f2: (name: string, no: number) => NormalUser

const f3 = factoryFactory(NU, (name: string, id: number) => {
  return {
    name,
    id,
  }
}) // Property 'no' is missing in type '{ name: string; id: number; }' but required in type 'Pick<NormalUser, "no" | "name">'.ts(2345)
console.log(f1({ name: 'mmmpa', no: 1 })) // {name: "mmmpa", no: 1}
console.log(f2('mmmpa', 1))               // {name: "mmmpa", no: 1}

まとめ

昨今の SPA ではクラスではなくプレーンなオブジェクトを循環させて関数で処理していくのが主流だと感じています。そこで今回はプレーンなオブジェクトも明確に区別できたら嬉しいねということでチャレンジしてみました。

しかし実際のところ、ここまでやることはなさそうです。