GraphQLのエラーハンドリングは型安全にできるのか?

GraphQL はサーバーサイドとクライアントサイドで一貫して型安全を実現できる、実に便利なクエリ言語である。だが、エラーハンドリングに関しては残念ながら型安全を実現できているとは言い難い。エラーまで型安全を実現するにはどのように実装すればよいのか、段階を踏んで見ていく。

Level0: 何も考えず例外を Throw

型安全を忘れて愚直にエラーを表現するならば、単純に例外を送出するだけでことが済む。以下は Schema と TypeScript での Resolver のコード例である。

type Query {
  echo(input: EchoInput!): EchoPayload!
}
 
type EchoPayload {
  message: String!
}
 
input EchoInput {
  message: String!
}
const echoQueryResolver = async (parent, variables, context, info) => {
  if (somethingWrong()) {
    throw new Error('Something wrong!');
  }
 
  return { message: variables.input.message };
};

Operation は次のようになり……

query Echo($input: EchoInput!) {
  echo(input: $input) {
    message
  }
}

レスポンスの JSON は次のようになる。

{
  "errors": [
    {
      "message": "Something wrong!",
      "locations": [{ "line": 2, "column": 3 }],
      "path": ["echo"]
    }
  ],
  "data": {
    "echo": null
  }
}

この時の errors の 形式はspecによって定められている。クライアント側の SyntaxError や UnexpectedDisconnection を表現するならばこの方法でも問題ないだろう。しかし、これを各 Field でのエラーハンドリングの標準的な処理方法として扱うにはいくつもの不便な点がある。

1: エラーに対して詳細な型を定義できない

実際にエラーハンドリングをする際、エラーの原因によって処理を分けるためにはエラーの種類を判別する必要がある。しかし、GraphQL 標準のエラーにおいて種類を判別するにはメッセージ内容によって区別するしかない。extensionsを用いれば任意の値をエラーに持たせることができるが、その型情報を定義する手段は提供されていない。

2: エラーの場所がフィールドと別れてしまう

Resolver で発生したエラーはerrors配列に格納される。それぞれのエラーはpathというプロパティにエラーの発生箇所を格納している。これを使えばエラーの起きたResolverFieldを特定することができるが、わざわざpathを使ってエラーハンドリングを行う例は見たことがない。取得されたデータは大抵何らかのコンポーネントに渡されるわけで、理想的にはエラーに対処するなら各コンポーネントで行うべきだろう。しかし、実際にはエラーが発生した箇所のデータはnullになってしまうので、エラーの原因についての情報はコンポーネントから知ることはできない。エラーの情報が発生箇所から離されているために扱いが面倒になってしまうのだ。

このような不都合は適切にエラーハンドリングを行わないために発生している。つまり、無造作に例外を送出するからエラーの情報が失われてしまうのであって、言い換えれば例外以外の方法でエラーを表現すればこれらの問題は解決することになる。そこで使われるのがUnionによるエラー表現である。

Level1: Union でエラーを表現

第一の解決策はエラーの型と成功時の型のUnionによってレスポンスを表現する方法だ。Schema は以下のようになる。

type EchoOk {
  message: String!
}
type EchoError {
  reason: EchoErrorReason!
}
enum EchoErrorReason {
  InternalServerError
}
union EchoPayload = EchoError | EchoOk

対応する Resolver は次のようになる。

const echoQueryResolver = async (parent, variables, context, info) => {
  if (somethingWrong()) {
    return {
      __typename: 'EchoError',
      reason: 'InternalServerError',
    };
  }
 
  return {
    __typename: 'EchoOk',
    message: variables.input.message,
  };
};

さらに Operation は次のようになる。

query Echo($input: EchoInput!) {
  echo(input: $input) {
    ... on EchoError {
      reason
    }
    ... on EchoOk {
      message
    }
  }
}

この方式は Rust では Result 型と呼ばれており、Zig などでも同様のパターンがよく用いられている。この方式であれば各 Field の返り値にエラーの情報を含めることになるので、当然エラーの型安全を実現することができる。典型的にはErrorReasonという Enum を Error 型に持たせておき、それをもとにクライアント側でエラーハンドリングを行うことになるだろう。

これでエラーに対しても型安全を実現できて一安心……とはいかない。

厄介な Directive でのエラーハンドリング

GraphQL には Directive という仕組みがある。これは異なる Type や Field に共通の処理を記述するために用いられるもので、Python でいうところの Decorator のように機能する。以下はリクエスト時にユーザーの認証状態を調べる Directive の例である。

directive @loginRequired(accessLevel: Int!) on FIELD_DEFINITION
 
type Query {
  echo(input: EchoInput!): EchoPayload! @loginRequired(accessLevel: 0)
}

ここで問題となるのが、Directive には返り値の型に関する情報が何一つ含まれていないという点である。つまり、Directive を付けることで String 型と定義されている Field から Int 型を返すような実装も可能であるのだ。そして当然例外を送出すれば Union によるエラー定義は全くの無意味となってしまう。つまり、Directive を使いつつ型安全を実現するには

  1. エラーの情報を Resolver に渡しつつ
  2. Resolver の返り値を変更しない

……という条件を満たす Directive を実装する必要がある。

Level2: Context にエラーを格納

そこで役に立つのが Context という仕組みだ。その名の通り Resolver の実行状況(Context)に関係する情報を保持しているオブジェクトで、リクエストの初めから最終的なデータを生成するまでそれぞれの Resolver に同じオブジェクトが渡される。よって Directive から Context に値を格納すれば Resolver に Directive の情報を渡すことができる、というわけだ。

Directive の実装は以下のようになる。

import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils';
 
export function loginRequiredDirectiveTransformer(schema) {
  const directiveName = 'loginRequired';
 
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const loginRequiredDirective = getDirective(
        schema,
        fieldConfig,
        directiveName
      )?.[0];
      if (!loginRequiredDirective) {
        return;
      }
      const { resolve = defaultFieldResolver } = fieldConfig;
 
      const resolver = async (parent, args, context, info) => {
        // あらかじめContextを拡張しておく。
        // GraphQLライブラリではリクエスト情報をContextに持たせていることが多い。
        const { request } = context;
        const user = getUser(request);
        if (!user) {
          context.directiveErrors.push({
            reason: 'UNAUTHORIZED',
            directiveName,
          });
          return await resolve(parent, args, context, info);
        }
 
        const { accessLevel } = loginRequiredDirective;
        if (user.accessLevel < accessLevel) {
          context.directiveErrors.push({
            reason: 'FORBIDDEN',
            directiveName,
          });
          return await resolve(parent, args, context, info);
        }
 
        return await resolve(parent, args, context, info);
 
        return await resolve(parent, args, context, info);
      };
      fieldConfig.resolve = resolver;
 
      return fieldConfig;
    },
  });
}

そしてこれに対応する Resolver は以下のようになる。

const echoQueryResolver = async (parent, variables, context, info) => {
  const directiveError = context.directiveErrors.at(0);
  if (directiveError) {
    // ここでエラーの内容を詳細に記述できる。
    return {
      __typename: 'EchoError',
      reason: 'InternalServerError',
    };
  }
 
  return {
    __typename: 'EchoOk',
    message: variables.input.message,
  };
};

Resolver 実行時に Context のエラー情報を確認して、問題があれば Resolver から Error 型を返す、という風になる。これで万事解決……ではない。

Input の Directive は Context に触れない

Directive は GraphQL の Schema 上の様々な部分につけることができる。これを利用して Input のバリデーションにも Directive を使うことができる。

directive @limitStringLength(length: Int!) on INPUT_FIELD_DEFINITION
 
input EchoInput {
  message: String! @limitStringLength(length: 64)
}

しかし、この場合 Context を利用したエラーハンドリングは実装できない。なぜなら、Context は Resolver=サーバーサイドでの実行状況を格納するためのものであって、Input=クライアントサイドの検証時には利用できないのだ。fieldConfigの型にresolveが含まれていないのもそのためである。

よって Level2 の方法は使えない。どうしても Resolver に情報を渡したいなら Directive 内で Input の内容にエラー情報を加えるしかない。妥協になってしまうが、Input の型自体を修正して対処しよう。

Level3: Input の型も Union にする

Input の Directive は Context を操作することが出来ないが、Input の値を変更することはできる。そこで、Directive 内のエラーを Resolver に Input の値として渡すことで Resolver にエラー情報を渡すことにする。ただし、Directive が付いている Input の Field の型を一つ一つ修正する必要がある。

directive @limitStringLength(length: Int!) on INPUT_FIELD_DEFINITION
 
scalar ValidatedString
 
input EchoInput {
  message: ValidatedString! @limitStringLength(length: 64)
}
type ValidatedString =
  | string
  | { blocked: true; inputLength: number; limitLength: number };
import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils';
import { GraphQLScalarType } from 'graphql';
 
class LimitedStringType extends GraphQLScalarType {
  constructor(length, baseType) {
    super({
      name: `StringWithLengthAtMost${length}`,
      parseValue: (value_) => {
        const value = 'parseValue' in baseType ? baseType.parseValue(value_) : value_;
 
        const parse = (v) => {
          if (typeof v !== 'string') {
            throw new Error(`Incorrect value type: ${v}`);
          }
          const givenLength = [...v].length;
 
          if (givenLength > length) {
            const invalid = {
              blocked: true,
              inputLength: givenLength,
              limitLength: length,
            };
            return invalid;
          }
          return v;
        };
        return parse(value);
      },
    });
  }
}
 
function limitStringLengthTransformer(schema) {
  const directiveName = 'limitStringLength';
 
  return mapSchema(schema, {
    [MapperKind.INPUT_OBJECT_FIELD]: (fieldConfig) => {
      const limitStringLengthDirective = getDirective(
        schema,
        fieldConfig,
        directiveName
      )?.[0];
 
      if (!limitStringLengthDirective) {
        return fieldConfig;
      }
 
      const { length } = limitStringLengthDirective;
      assert(length && length > 0, 'Invalid directive args');
 
      const type = new LimitedStringType(length, fieldConfig.type);
      fieldConfig.type = type;
      return fieldConfig;
    },
  });
}
const echoQueryResolver = async (parent, variables, context, info) => {
  const directiveError = context.directiveErrors.at(0);
  if (directiveError) {
    return {
      __typename: 'EchoError',
      reason: 'InternalServerError',
    };
  }
 
  const { message } = variables.input;
  if (typeof message !== 'string') {
    return {
      __typename: 'EchoError',
      reason: 'BadRequest',
    };
  }
 
  return {
    __typename: 'EchoOk',
    message,
  };
};

これでようやくエラーハンドリングを型安全に行うことが出来るようになった。GraphQL は便利な仕組みだが、ところどころに使い勝手の悪い仕様が残ってしまっている。今から改めて仕様を作ったらより型安全とモジュール化を意識したものになるかもしれない……。