T-CREATOR

Zod で URL・検索パラメータを型安全に扱う手順(SPA/SSR 共通)

Zod で URL・検索パラメータを型安全に扱う手順(SPA/SSR 共通)

Web アプリケーション開発において、URL パラメータや検索パラメータ(クエリパラメータ)の扱いは避けて通れない課題です。特に TypeScript を使った開発では、型安全性を保ちながらパラメータを扱いたいですよね。

しかし、URL から取得したパラメータは常に文字列型であり、期待する型への変換やバリデーションが必要になります。そこで登場するのが Zod です。Zod を活用することで、SPA(Single Page Application)でも SSR(Server Side Rendering)でも共通の方法で、型安全かつバリデーション付きのパラメータ処理が実現できます。

本記事では、Zod を使って URL・検索パラメータを型安全に扱う具体的な手順を、実践的なコード例とともに解説していきます。

背景

Web アプリケーションにおける URL パラメータの重要性

現代の Web アプリケーションでは、URL はユーザー体験を左右する重要な要素となっています。検索条件、ページネーション、フィルター設定など、アプリケーションの状態を URL に反映させることで、以下のようなメリットが得られます。

  • ブックマーク可能性: ユーザーが特定の状態を保存できる
  • 共有可能性: URL を共有するだけで同じ状態を再現できる
  • SEO 対策: 検索エンジンがコンテンツを正しくインデックス化できる
  • ブラウザの戻る・進むボタンの動作: 自然なナビゲーション体験を提供

TypeScript と URL パラメータの相性問題

TypeScript はコンパイル時の型チェックにより、開発時の安全性を大幅に向上させます。しかし、URL パラメータに関しては以下のような問題があります。

typescript// ブラウザの URL API や Next.js の router から取得したパラメータ
const searchParams = new URLSearchParams(
  window.location.search
);
const page = searchParams.get('page'); // string | null 型
const limit = searchParams.get('limit'); // string | null 型

取得した値は常に string | null 型であり、数値や真偽値として扱いたい場合は手動で変換が必要です。また、パラメータが存在しない場合や不正な値の場合の処理も考慮しなければなりません。

Zod の登場とその役割

Zod は TypeScript ファーストのスキーマ検証ライブラリです。ランタイムでのバリデーションと、TypeScript の型推論を同時に実現できる点が特徴です。

Zod を使うことで、以下のような処理を簡潔に記述できます。

  • スキーマ定義: データの形状を宣言的に定義
  • バリデーション: 実行時に値が期待通りか検証
  • 型推論: スキーマから TypeScript の型を自動生成
  • 変換処理: 文字列から数値への変換などを組み込める

URL パラメータの処理に Zod を適用することで、型安全性とバリデーションを同時に実現できるのです。

以下の図は、Zod を使った URL パラメータ処理の全体像を示しています。

mermaidflowchart TB
  url["URL<br/>(例: ?page=2&limit=10)"] --> extract["パラメータ抽出"]
  extract --> raw["生データ<br/>(string | null)"]
  raw --> zod["Zod スキーマ"]
  zod --> validate{バリデーション}
  validate -->|成功| typed["型安全なデータ<br/>(number, boolean など)"]
  validate -->|失敗| error["エラーハンドリング<br/>(デフォルト値/エラー表示)"]
  typed --> app["アプリケーション<br/>ロジック"]
  error --> app

この図が示す通り、Zod はバリデーションと型変換の橋渡しをしてくれます。

課題

従来の URL パラメータ処理における課題

Zod を使わない従来の方法では、以下のような課題がありました。

課題 1: 型安全性の欠如

URL パラメータは常に文字列として取得されるため、型の不一致によるバグが発生しやすくなります。

typescript// 従来の方法
const page = searchParams.get('page');
const pageNumber = parseInt(page || '1'); // NaN になる可能性

// pageNumber が NaN かどうかチェックが必要
if (isNaN(pageNumber)) {
  // エラー処理
}

この方法では、型チェックがランタイムまで持ち越されます。

課題 2: バリデーションロジックの散在

パラメータごとに個別のバリデーションを書く必要があり、コードが冗長になります。

typescript// 複数のパラメータを個別にバリデーション
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '10');
const sort = searchParams.get('sort') || 'desc';

// それぞれのバリデーション
if (page < 1) page = 1;
if (limit < 1 || limit > 100) limit = 10;
if (sort !== 'asc' && sort !== 'desc') sort = 'desc';

バリデーションロジックが分散し、メンテナンスが困難になります。

課題 3: エラーハンドリングの複雑さ

どのパラメータでエラーが発生したのか、どう対処すべきかが不明瞭になりがちです。

#課題具体例影響
1型安全性の欠如string から number への変換ミスランタイムエラー、NaN の発生
2バリデーション散在個別の条件分岐が増えるコードの可読性低下、保守性悪化
3エラー原因不明どのパラメータが不正か分からないデバッグ困難、ユーザー体験の悪化
4テストの困難さ多数の条件分岐をカバーする必要テストコード肥大化

課題 4: SPA と SSR での処理の違い

React や Vue などの SPA と、Next.js や Nuxt.js などの SSR フレームワークでは、パラメータ取得方法が異なります。

typescript// SPA (React)
const searchParams = new URLSearchParams(
  window.location.search
);

// SSR (Next.js App Router)
const searchParams = useSearchParams();

// SSR (Next.js Pages Router)
const { query } = useRouter();

フレームワークごとに異なる API を使い分ける必要があり、共通化が困難でした。

以下の図は、従来の処理フローにおける問題点を示しています。

mermaidflowchart LR
  params["URL パラメータ"] --> manual["手動パース<br/>(parseInt など)"]
  manual --> check1{page チェック}
  check1 --> check2{limit チェック}
  check2 --> check3{sort チェック}
  check3 --> result["処理結果"]

  check1 -.->|失敗| error1["個別エラー処理"]
  check2 -.->|失敗| error2["個別エラー処理"]
  check3 -.->|失敗| error3["個別エラー処理"]

  style error1 fill:#f99
  style error2 fill:#f99
  style error3 fill:#f99

この図から分かるように、エラーハンドリングが分散し、コードが複雑になっていることが分かります。

解決策

Zod による統一的なアプローチ

Zod を使うことで、上記の課題を一気に解決できます。スキーマ定義によってバリデーションと型推論を同時に実現し、SPA と SSR の両方で共通の処理を使えるようになります。

以下の図は、Zod を使った解決策の全体フローを示しています。

mermaidflowchart TB
  schema["① Zod スキーマ定義<br/>(型とバリデーション)"] --> parser["② パーサー関数作成"]
  parser --> spa["③ SPA での利用<br/>(window.location)"]
  parser --> ssr["③ SSR での利用<br/>(Next.js など)"]
  spa --> validate["④ バリデーション実行"]
  ssr --> validate
  validate -->|成功| typed["⑤ 型安全なデータ"]
  validate -->|失敗| handle["⑥ 統一エラーハンドリング"]
  handle --> default["デフォルト値適用"]
  typed --> use["⑦ アプリケーション利用"]
  default --> use

  style schema fill:#9cf
  style typed fill:#9f9
  style handle fill:#fc9

この図が示す通り、Zod を中心とした統一的なフローで処理を行います。

解決策 1: Zod スキーマによる宣言的な定義

まず、Zod をインストールします。

bashyarn add zod

次に、URL パラメータのスキーマを定義します。スキーマにはバリデーションルールと型情報の両方が含まれます。

typescriptimport { z } from 'zod';

// 検索パラメータのスキーマ定義
const searchParamsSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce
    .number()
    .int()
    .min(1)
    .max(100)
    .default(10),
  sort: z.enum(['asc', 'desc']).default('desc'),
  query: z.string().optional(),
});

このスキーマ定義により、以下の情報が一箇所にまとまります。

パラメータバリデーションデフォルト値
pagenumber1 以上の整数1
limitnumber1〜100 の整数10
sort'asc' または 'desc'列挙値のいずれか'desc'
querystring (optional)なしundefined

z.coerce.number() を使うことで、文字列から数値への自動変換が行われます。これにより parseInt などの手動変換が不要になります。

解決策 2: 型推論の活用

Zod スキーマから TypeScript の型を自動生成できます。

typescript// スキーマから型を推論
type SearchParams = z.infer<typeof searchParamsSchema>;

// 以下の型が自動生成される
// type SearchParams = {
//   page: number;
//   limit: number;
//   sort: "asc" | "desc";
//   query?: string | undefined;
// }

この型は他のコンポーネントや関数でも使い回せます。手動で型定義を書く必要がなくなり、スキーマと型の不整合を防げます。

解決策 3: 共通パーサー関数の作成

SPA と SSR の両方で使える汎用的なパーサー関数を作成します。

typescript// URL パラメータをパースする共通関数
function parseSearchParams(
  params:
    | URLSearchParams
    | Record<string, string | string[]>
) {
  // URLSearchParams を Record に変換
  const paramsObject: Record<string, string> = {};

  if (params instanceof URLSearchParams) {
    params.forEach((value, key) => {
      paramsObject[key] = value;
    });
  } else {
    // Next.js の query オブジェクトなどを想定
    Object.entries(params).forEach(([key, value]) => {
      paramsObject[key] = Array.isArray(value)
        ? value[0]
        : value;
    });
  }

  return paramsObject;
}

この関数は URLSearchParams オブジェクトとプレーンオブジェクトの両方を受け入れます。これにより、異なる環境でも同じインターフェースで扱えるようになります。

続いて、Zod を使った実際のパース処理を実装します。

typescript// Zod でバリデーション付きパース
function parseAndValidateSearchParams(
  params:
    | URLSearchParams
    | Record<string, string | string[]>
): SearchParams {
  const paramsObject = parseSearchParams(params);

  // Zod でパース(バリデーション + 型変換)
  const result = searchParamsSchema.safeParse(paramsObject);

  if (!result.success) {
    console.warn(
      'URL パラメータのバリデーションエラー:',
      result.error
    );
    // デフォルト値を使用
    return searchParamsSchema.parse({});
  }

  return result.data;
}

safeParse を使うことで、バリデーションエラー時に例外をスローせず、エラー情報を取得できます。エラー時はデフォルト値を適用した安全な値を返します。

解決策 4: エラーハンドリングの統一

Zod のエラー情報を活用して、詳細なエラーハンドリングを実装できます。

typescriptimport { ZodError } from 'zod';

// エラー詳細を取得する関数
function getValidationErrors(error: ZodError) {
  return error.issues.map((issue) => ({
    path: issue.path.join('.'),
    message: issue.message,
    code: issue.code,
  }));
}

この関数により、どのパラメータでどのようなエラーが発生したかを明確に把握できます。

エラー情報を使った実装例です。

typescriptfunction parseWithDetailedErrors(
  params:
    | URLSearchParams
    | Record<string, string | string[]>
): {
  data: SearchParams;
  errors: Array<{ path: string; message: string }> | null;
} {
  const paramsObject = parseSearchParams(params);
  const result = searchParamsSchema.safeParse(paramsObject);

  if (!result.success) {
    return {
      data: searchParamsSchema.parse({}), // デフォルト値
      errors: getValidationErrors(result.error),
    };
  }

  return { data: result.data, errors: null };
}

この実装により、エラー情報を保持しながら処理を継続できます。ユーザーにエラーメッセージを表示したい場合などに有用です。

具体例

SPA(React)での実装例

React アプリケーションで URL パラメータを扱う具体例を見ていきましょう。

カスタムフックの作成

まず、Zod を使った検索パラメータ取得のカスタムフックを作成します。

typescriptimport { useSearchParams } from 'react-router-dom';
import { useMemo } from 'react';

// カスタムフック: 型安全な検索パラメータ取得
function useTypedSearchParams() {
  const [searchParams] = useSearchParams();

  const typedParams = useMemo(() => {
    return parseAndValidateSearchParams(searchParams);
  }, [searchParams]);

  return typedParams;
}

useMemo を使うことで、パラメータが変更されない限り再計算を避けています。これによりパフォーマンスが向上します。

コンポーネントでの利用

作成したカスタムフックをコンポーネント内で使用します。

typescript// 商品一覧コンポーネント
function ProductList() {
  // 型安全なパラメータ取得
  const params = useTypedSearchParams();

  // params は SearchParams 型で型安全
  // params.page は number 型
  // params.sort は "asc" | "desc" 型

  return (
    <div>
      <h1>商品一覧</h1>
      <p>ページ: {params.page}</p>
      <p>表示件数: {params.limit}</p>
      <p>並び順: {params.sort}</p>
      {params.query && (
        <p>検索キーワード: {params.query}</p>
      )}
    </div>
  );
}

型推論により、IDE の補完が効き、typo によるバグを防げます。また、不正な値が渡されることがないため、安全に処理を進められます。

パラメータ更新の実装

検索パラメータを更新する機能も型安全に実装できます。

typescriptimport { useSearchParams } from 'react-router-dom';

function ProductFilters() {
  const [, setSearchParams] = useSearchParams();

  // ページを変更する関数
  const handlePageChange = (newPage: number) => {
    setSearchParams((prev) => {
      const params = parseAndValidateSearchParams(prev);
      return {
        ...params,
        page: newPage,
      } as any; // URLSearchParams の制約を回避
    });
  };

  return (
    <div>
      <button onClick={() => handlePageChange(1)}>
        1ページ目
      </button>
      <button onClick={() => handlePageChange(2)}>
        2ページ目
      </button>
    </div>
  );
}

既存のパラメータを保持しながら、特定のパラメータだけを更新できます。

SSR(Next.js App Router)での実装例

Next.js の App Router では、Server Component と Client Component で異なるアプローチが必要です。

Server Component での利用

Server Component では searchParams が props として渡されます。

typescript// app/products/page.tsx
import { z } from 'zod';

// ページコンポーネント(Server Component)
export default function ProductsPage({
  searchParams,
}: {
  searchParams: {
    [key: string]: string | string[] | undefined;
  };
}) {
  // Zod でパースとバリデーション
  const params = parseAndValidateSearchParams(searchParams);

  return (
    <div>
      <h1>商品一覧(SSR)</h1>
      <p>ページ: {params.page}</p>
      <p>表示件数: {params.limit}</p>
      <ProductListServer params={params} />
    </div>
  );
}

Server Component では直接データベースアクセスなどの処理を行えるため、パラメータを使った検索処理を実装します。

typescript// 商品データ取得(サーバーサイド)
async function ProductListServer({
  params,
}: {
  params: SearchParams;
}) {
  // データベースから商品を取得(例)
  const products = await db.product.findMany({
    skip: (params.page - 1) * params.limit,
    take: params.limit,
    orderBy: { createdAt: params.sort },
    where: params.query
      ? {
          name: { contains: params.query },
        }
      : undefined,
  });

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

型安全なパラメータをそのままデータベースクエリに使用できます。

Client Component での利用

Client Component では useSearchParams フックを使用します。

typescript'use client';

import { useSearchParams } from 'next/navigation';

// クライアントコンポーネント
export function ProductFiltersClient() {
  const searchParams = useSearchParams();
  const params = parseAndValidateSearchParams(searchParams);

  return (
    <div>
      <p>現在のフィルター設定</p>
      <p>ページ: {params.page}</p>
      <p>並び順: {params.sort}</p>
    </div>
  );
}

SPA と同様に、型安全にパラメータを扱えます。

SSR(Next.js Pages Router)での実装例

Pages Router では useRouter フックを使用します。

typescriptimport { useRouter } from 'next/router';

function ProductsPageLegacy() {
  const router = useRouter();

  // router.query を Zod でパース
  const params = parseAndValidateSearchParams(router.query);

  return (
    <div>
      <h1>商品一覧(Pages Router)</h1>
      <p>ページ: {params.page}</p>
    </div>
  );
}

router.queryRecord<string, string | string[]> 型なので、共通のパーサー関数がそのまま使えます。

以下の図は、各環境での実装フローを比較したものです。

mermaidflowchart TB
  subgraph spa["SPA (React)"]
    spa1["useSearchParams()"] --> spa2["URLSearchParams"]
  end

  subgraph ssr_app["Next.js App Router"]
    ssr1["searchParams props"] --> ssr2["Record&lt;string, string&gt;"]
  end

  subgraph ssr_pages["Next.js Pages Router"]
    ssr3["router.query"] --> ssr4["Record&lt;string, string&gt;"]
  end

  spa2 --> common["共通パーサー関数<br/>(parseSearchParams)"]
  ssr2 --> common
  ssr4 --> common
  common --> zod["Zod バリデーション"]
  zod --> result["型安全な SearchParams"]

  style common fill:#9cf
  style result fill:#9f9

このように、環境に依存せず共通の処理フローで実装できることが分かります。

エラーハンドリングの実装例

実際のアプリケーションでは、エラーをユーザーに分かりやすく表示する必要があります。

typescriptfunction ProductPageWithErrors() {
  const searchParams = useSearchParams();
  const { data: params, errors } =
    parseWithDetailedErrors(searchParams);

  return (
    <div>
      {errors && (
        <div
          style={{
            background: '#fee',
            padding: '1rem',
            marginBottom: '1rem',
          }}
        >
          <h3>パラメータエラー</h3>
          <ul>
            {errors.map((err, idx) => (
              <li key={idx}>
                <strong>{err.path}</strong>: {err.message}
              </li>
            ))}
          </ul>
          <p>デフォルト値を使用しています。</p>
        </div>
      )}

      <ProductList params={params} />
    </div>
  );
}

エラーが発生した場合でもアプリケーションは正常に動作し、ユーザーには問題があったことが通知されます。

実践的なエラーコード例

実際に発生する可能性のあるエラーとその対処方法を見ていきます。

エラーコード: invalid_type

パラメータの型が期待と異なる場合に発生します。

typescript// エラー発生例
// URL: ?page=abc&limit=10
// エラーメッセージ: Expected number, received nan

// エラー内容
{
  code: 'invalid_type',
  expected: 'number',
  received: 'nan',
  path: ['page'],
  message: 'Expected number, received nan'
}

解決方法: z.coerce.number() を使用することで、文字列から数値への変換を試みます。変換できない場合はデフォルト値が使用されます。

エラーコード: too_small / too_big

値が範囲外の場合に発生します。

typescript// エラー発生例
// URL: ?page=-1&limit=200
// エラーメッセージ: Number must be greater than or equal to 1

// page のエラー
{
  code: 'too_small',
  minimum: 1,
  type: 'number',
  inclusive: true,
  path: ['page'],
  message: 'Number must be greater than or equal to 1'
}

// limit のエラー
{
  code: 'too_big',
  maximum: 100,
  type: 'number',
  inclusive: true,
  path: ['limit'],
  message: 'Number must be less than or equal to 100'
}

解決方法: .min().max() でバリデーションルールを定義し、エラー時はデフォルト値を使用します。

エラーコード: invalid_enum_value

列挙値以外の値が指定された場合に発生します。

typescript// エラー発生例
// URL: ?sort=random
// エラーメッセージ: Invalid enum value. Expected 'asc' | 'desc', received 'random'

// エラー内容
{
  code: 'invalid_enum_value',
  options: ['asc', 'desc'],
  received: 'random',
  path: ['sort'],
  message: "Invalid enum value. Expected 'asc' | 'desc', received 'random'"
}

解決方法: z.enum() で許可する値を定義し、それ以外の値はデフォルト値に置き換えます。

以下の表は、主要なエラーコードと対処方法をまとめたものです。

#エラーコード発生条件対処方法デフォルト値の適用
1invalid_type型変換失敗z.coerce で自動変換
2too_small最小値未満.min() でバリデーション
3too_big最大値超過.max() でバリデーション
4invalid_enum_value列挙値外z.enum() で制限
5invalid_string文字列形式不正.email(), .url() など

応用例: 複雑なスキーマ

実際のアプリケーションでは、より複雑なパラメータ構造が必要になることがあります。

typescript// 日付範囲フィルターを含む複雑なスキーマ
const advancedSearchParamsSchema = z
  .object({
    page: z.coerce.number().int().min(1).default(1),
    limit: z.coerce
      .number()
      .int()
      .min(1)
      .max(100)
      .default(10),
    sort: z.enum(['asc', 'desc']).default('desc'),
    query: z.string().optional(),

    // カテゴリフィルター(カンマ区切り文字列を配列に変換)
    categories: z
      .string()
      .transform((str) => str.split(',').filter(Boolean))
      .pipe(z.array(z.string()))
      .optional(),

    // 日付範囲フィルター
    dateFrom: z.coerce.date().optional(),
    dateTo: z.coerce.date().optional(),

    // 価格範囲フィルター
    priceMin: z.coerce.number().min(0).optional(),
    priceMax: z.coerce.number().min(0).optional(),
  })
  .refine(
    (data) => {
      // dateTo は dateFrom より後でなければならない
      if (data.dateFrom && data.dateTo) {
        return data.dateTo >= data.dateFrom;
      }
      return true;
    },
    {
      message: '終了日は開始日以降である必要があります',
      path: ['dateTo'],
    }
  );

このスキーマでは、以下の高度な機能を使用しています。

  • transform: 文字列をカンマで分割して配列に変換
  • pipe: 変換後の値に対してさらにバリデーション
  • refine: カスタムバリデーションロジック(日付の前後関係チェック)

複雑なバリデーションルールも Zod で一箇所に集約できます。

typescript// 使用例
const result = advancedSearchParamsSchema.safeParse({
  page: '2',
  categories: 'electronics,books',
  dateFrom: '2024-01-01',
  dateTo: '2024-12-31',
  priceMin: '1000',
  priceMax: '5000',
});

if (result.success) {
  // result.data.categories は string[] 型
  // result.data.dateFrom は Date 型
  console.log(result.data);
}

型推論により、変換後の正しい型が自動的に推論されます。

まとめ

本記事では、Zod を使って URL・検索パラメータを型安全に扱う方法を解説しました。重要なポイントを振り返りましょう。

得られたメリット

型安全性の確保: Zod のスキーマから TypeScript の型が自動生成されるため、コンパイル時とランタイムの両方で型安全性が保証されます。string | null 型のパラメータを扱う際の不安から解放されます。

バリデーションの一元管理: パラメータごとに散らばっていたバリデーションロジックを、スキーマ定義として一箇所に集約できます。メンテナンス性が大幅に向上し、新しいパラメータの追加も容易になりました。

SPA と SSR の共通化: React、Next.js App Router、Next.js Pages Router など、異なる環境でも同じパーサー関数とスキーマを使い回せます。環境ごとに実装を変える必要がなくなり、コードの統一性が保たれます。

詳細なエラーハンドリング: Zod のエラー情報により、どのパラメータでどのようなエラーが発生したかを正確に把握できます。ユーザーへの適切なフィードバックが可能になり、デバッグも容易になります。

実装のポイント

スキーマ定義では、以下の機能を活用することが重要です。

  • z.coerce による自動型変換
  • .default() によるデフォルト値設定
  • .optional() によるオプショナル指定
  • z.enum() による列挙値の制限
  • .refine() によるカスタムバリデーション

また、safeParse を使うことでエラー時の処理を柔軟に制御できます。例外をスローせずエラー情報を取得し、デフォルト値で処理を継続する実装パターンが有効です。

導入時の注意点

Zod を導入する際は、以下の点に注意してください。

既存コードへの影響を最小限にするため、まずは小さな範囲から導入を始めましょう。特定のページやコンポーネントで試してから、徐々に適用範囲を広げることをお勧めします。

パフォーマンスについては、useMemouseCallback を活用してバリデーション処理の最適化を行います。毎回のレンダリングでスキーマをパースするのではなく、パラメータが変更された時のみ再実行するようにしましょう。

エラーメッセージは、開発者向けとユーザー向けで使い分けることが大切です。Zod のエラーメッセージは技術的な内容が含まれるため、そのままユーザーに表示するのではなく、分かりやすい日本語メッセージに変換する処理を追加すると良いでしょう。

今後の発展

Zod による URL パラメータ処理は、以下のような応用が可能です。

フォームバリデーションとの統合により、URL パラメータとフォーム入力の両方で同じスキーマを使い回せます。これにより、検索フィルターとフォーム送信のバリデーションロジックを共通化できます。

API レスポンスのバリデーションにも Zod を活用することで、フロントエンド全体の型安全性を向上させられます。外部 API からのレスポンスを Zod でバリデーションすることで、予期しないデータ構造によるバグを防げます。

URL パラメータの履歴管理では、Zod で検証済みのパラメータを localStorage などに保存し、ユーザーの検索条件を記憶する機能を実装できます。

Web 開発において URL パラメータは避けられない要素ですが、Zod を使うことで型安全かつ保守性の高い実装が実現できます。ぜひプロジェクトに導入して、その恩恵を体験してください。

関連リンク