0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【TypeScript】ドット記法のパスからネストした型を逆引きする PathType<T, P>

0
Posted at

はじめに

前回の記事で、ネストしたオブジェクトのキーをドット記法のUnion型として生成する Path<T> を紹介しました。

Path<T>「どのパスが存在するか」 は型安全に扱えるようになりましたが、もう一つ欲しいものがあります。それは 「そのパスの先にある値は何型なのか」 という情報です。

// Path<T> で得られるのはパスの文字列だけ
type AppStatePath = Path<AppState>;
// "user" | "ui" | "user.id" | "user.preferences" | ...

// 欲しいのは「"user.preferences.theme" の型は "light" | "dark" だよ」という情報

この記事では、ドット記法のパス文字列から対応する値の型を自動的に解決する PathType<T, P> を紹介します。

本記事は @KaraBharat氏のツイート を参考に、実務での活用方法を補足してまとめたものです。

完成形

// 前回の Path<T>(パスのUnion型を生成)
type Path<T> = T extends object
  ? {
      [K in keyof T]: K extends string ? `${K}` | `${K}.${Path<T[K]>}` : never;
    }[keyof T]
  : never;

// 今回の PathType<T, P>(パスから値の型を解決)
type PathType<T, P extends Path<T>> = P extends `${infer Key}.${infer Rest}`
  ? Key extends keyof T
    ? Rest extends Path<T[Key]>
      ? PathType<T[Key], Rest>
      : never
    : never
  : P extends keyof T
    ? T[P]
    : never;

使ってみるとこうなります。

interface AppState {
  user: {
    id: string;
    preferences: {
      theme: "light" | "dark";
      lang: "en" | "es";
    };
  };
  ui: {
    sidebarOpen: boolean;
  };
}

type ThemeType   = PathType<AppState, "user.preferences.theme">;
// "light" | "dark"

type SidebarType = PathType<AppState, "ui.sidebarOpen">;
// boolean

パス文字列を渡すだけで、その先にある型が自動的に解決されます。

型の仕組みを分解して理解する

PathType は再帰的にドットで区切りながらオブジェクトを掘り下げていきます。

全体構造

大きく分けて 2つの分岐 があります。

type PathType<T, P extends Path<T>> =
  P extends `${infer Key}.${infer Rest}`   // ① ドットを含む場合
    ? ...
    : P extends keyof T                     // ② ドットを含まない場合(末端)
      ? T[P]
      : never;

分岐①: ドットを含む場合(再帰)

P extends `${infer Key}.${infer Rest}`

Template Literal Type の infer で、パスを 最初のドットの前後に分割 します。

パス P Key Rest
"user.preferences.theme" "user" "preferences.theme"
"user.id" "user" "id"

分割できたら、Key で1階層掘り下げて、残りの Rest で再帰します。

Key extends keyof T              // Key がオブジェクトのキーとして存在するか
  ? Rest extends Path<T[Key]>    // Rest が T[Key] の有効なパスか
    ? PathType<T[Key], Rest>     // → 1階層掘り下げて再帰
    : never
  : never

たとえば PathType<AppState, "user.preferences.theme"> は以下のように展開されます。

PathType<AppState, "user.preferences.theme">
  Key = "user", Rest = "preferences.theme"
  // → PathType<AppState["user"], "preferences.theme">

PathType<{ id: string; preferences: { theme: ...; lang: ... } }, "preferences.theme">
  Key = "preferences", Rest = "theme"
  // → PathType<{ theme: "light" | "dark"; lang: "en" | "es" }, "theme">

PathType<{ theme: "light" | "dark"; lang: "en" | "es" }, "theme">
  // ドットなし → 分岐② へ
  // → T["theme"]
  // → "light" | "dark"  ✅

分岐②: ドットを含まない場合(末端)

P extends keyof T
  ? T[P]       // そのキーの型を返す
  : never;

再帰の終了条件です。パスにドットがなければ、そのまま Indexed Access Type T[P] で値の型を取得します。

Path<T>PathType<T, P> を組み合わせる

この2つを一緒に使うと、型安全な get / set 関数 が作れます。

型安全な getter

function getByPath<T, P extends Path<T>>(
  obj: T,
  path: P,
): PathType<T, P> {
  return path
    .split(".")
    .reduce((acc: any, key) => acc?.[key], obj) as PathType<T, P>;
}

const state: AppState = {
  user: { id: "1", preferences: { theme: "dark", lang: "en" } },
  ui: { sidebarOpen: false },
};

const theme = getByPath(state, "user.preferences.theme");
// 型: "light" | "dark"  ← 推論される!

const sidebar = getByPath(state, "ui.sidebarOpen");
// 型: boolean

戻り値の型が自動的に解決されるので、取得した値をそのまま型安全に使えます。

型安全な setter

function setByPath<T, P extends Path<T>>(
  obj: T,
  path: P,
  value: PathType<T, P>,
): T {
  const keys = path.split(".");
  const result = structuredClone(obj) as any;
  let current = result;

  for (let i = 0; i < keys.length - 1; i++) {
    current = current[keys[i]];
  }
  current[keys[keys.length - 1]] = value;

  return result;
}

// ✅ theme には "light" | "dark" しか入れられない
setByPath(state, "user.preferences.theme", "light");

// ❌ コンパイルエラー: "blue" は "light" | "dark" に代入不可
setByPath(state, "user.preferences.theme", "blue");

// ❌ コンパイルエラー: boolean を期待するところに string
setByPath(state, "ui.sidebarOpen", "yes");

フォームフィールドのバインディング

React Hook Form 的なフォーム管理で、フィールドの型を自動推論させることもできます。

function useField<T, P extends Path<T>>(
  form: T,
  path: P,
): {
  value: PathType<T, P>;
  onChange: (next: PathType<T, P>) => void;
} {
  return {
    value: getByPath(form, path),
    onChange: (next) => setByPath(form, path, next),
  };
}

const themeField = useField(state, "user.preferences.theme");
// themeField.value:    "light" | "dark"
// themeField.onChange:  (next: "light" | "dark") => void

前回の Path<T> との関係

2つの型は セットで使う ことで真価を発揮します。

役割 返すもの
Path<T> 有効なパスを列挙する "user" | "user.id" | "user.preferences.theme" | ...
PathType<T, P> パスから値の型を解決する "light" | "dark"boolean など

Path<T> だけだと「パスは正しい」ことしか保証できませんが、PathType<T, P> を加えることで「値の型も正しい」ことまで保証できるようになります。

まとめ

PathType<T, P> で使われているテクニックを整理します。

テクニック 役割
P extends `${infer Key}.${infer Rest}` Template Literal Type + infer でドット区切りを分割
Key extends keyof T 分割したキーがオブジェクトに存在するか検証
PathType<T[Key], Rest> 再帰で1階層ずつ掘り下げ
T[P] Indexed Access Type で末端の型を取得

Path<T>PathType<T, P> を組み合わせれば、ネストしたオブジェクトに対するアクセスをパスの存在チェックと値の型チェックの両方でコンパイル時に保証できます。状態管理、フォーム、設定ファイルなど、ドット記法でアクセスするパターンがあれば導入を検討してみてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?