[CDK][Python]絶対に合わない型

（FLINTERS設立10周年ブログリレー84日目）

# Summary
PythonでCDKを書くと絶対に型が合わない箇所が生じる可能性があります。型安全愛好家の皆さんはある程度はあきらめましょう。

# 概要
例えばCDKを使ってLambdaとAPI Gatewayの構成を作るとします。Typescriptで書くと以下のような感じになると思います。
```typescript
import * as cdk from "aws-cdk-lib";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as apigateway from "aws-cdk-lib/aws-apigateway";
import { Construct } from "constructs";

class TmpStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const lambdaFunction = new lambda.Function(this, "tmp-function", {
      runtime: lambda.Runtime.NODEJS_18_X,
      code: lambda.Code.fromAsset("./tmp"),
      handler: "handler",
    });

    const api = new apigateway.RestApi(this, "tmp-api");
    const resource = api.root.addResource("tmp");
    resource.addMethod("GET", new apigateway.LambdaIntegration(lambdaFunction));
  }
}
```
Pythonの場合は以下のようになりますが、
```python
from aws_cdk import Stack, aws_lambda as _lambda, aws_apigateway as apigateway
from constructs import Construct


class TmpStack(Stack):
    def __init__(
        self, scope: Construct, construct_id: str, deploy_env: str, **kwargs
    ) -> None:
        lambda_function = _lambda.Function(
            self,
            "tmp-function",
            runtime=_lambda.Runtime.NODEJS_18_X,
            code=_lambda.Code.from_asset("./tmp"),
            handler="handler",
        )
        api = apigateway.RestApi(self, "tmp-api")
        resource = api.root.add_resource("tmp")
        resource.add_method("GET", apigateway.LambdaIntegration(lambda_function))
```
Pyright等で型チェックをしているとエラーが発生します。

![スクリーンショット 2023-11-28 18.02.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/660841/fda38aea-2f29-02b1-fea6-61203d15f7c9.png)

ちなみにTypescriptで書いた場合は型エラーは発生しません。

エラーの内容を見てみると
> "Function" is incompatible with protocol "IFunction"

`Function`が`IFunction`を実装していない？？？そんなことあるか？？？？？

# 原因
このエラーの原因を解明するにはCDKの多言語対応の仕組みを理解する必要があります。

CDKはTypescript, Python, Java, Goなど様々な言語で書くことができますが、大元のコード自体はTypescriptで書かれていて、[jsii](https://github.com/aws/jsii)という仕組みを用いて他の言語からも呼べるようになっています。また、jsiiは各言語間の差異を吸収する働きも担っているようです。例えばGo言語は`Optional`を表現する手段に乏しいため、Typescriptでいうところの
```typescript
type SomeProps = {
  name?: string
}
```
のような必須でないパラメータを表現することが難しくなっています。そこでjsiiは`jsii.Number`や`jsii.String`といった構造体を提供することで、Go言語でも`Optional`なパラメータを指定しやすくしています。[^1]

本題に戻ります。さきほどの型エラーの原因は、TypescriptとPythonの言語機能の差異によるものです。TypescriptでInterfaceを実装する場合、以下のようになります。
```typescript
interface TmpInterface {
  doSomething(someParam: string): void;
}

class TmpImplementation implements TmpInterface {
  doSomething(differentName: string): void {
    console.log(differentName);
  }
}
```
ここで重要なのは、Typescriptには名前付き引数の機能がないため、メソッドの引数に割り当てる変数名が異なっていたとしても継承関係とみなされるということです。上の例ではInterface上はメソッドの引数は`someParam`になっていますが、実装したクラス側では`differentName`になっています。変数名は異なるものの、メソッド全体のシグネチャは一致しているので、上の例は継承関係とみなされます。

ではPythonの場合はどうでしょう。上記のコードはjsiiを介したPython側では以下のように解釈されるようです。
```python
class TmpInterface(abc.ABCMeta):
    def do_something(self, some_param: str) -> None:
        raise NotImplementedError()

class TmpImplementation(TmpInterface):
    def do_something(self, different_name: str) -> None:
        print(different_name)
```
Pythonには名前付き引数の機能があるため、`TmpInterface`のインスタンスに対して
```python
tmp_instance.do_something(some_param = "hoge")
```
と呼ぶことができます。逆に`TmpInterface`を実装するクラスは上記のような呼び方をされても機能するために、引数の名前が`TmpInterface`と一致している必要があります。よって、上記のPythonの例は継承関係とみなされません。

Typescript上では継承関係にあるはずのクラスがPython上では継承関係ではなくなってしまう、これが今回の問題の根本的な原因です。[^2]

冒頭のエラーメッセージを詳しく見てみましょう。
> Argument of type "Function" cannot be assigned to parameter "handler" of type "IFunction" in function "__init__"
  "Function" is incompatible with protocol "IFunction"
    "grant_invoke" is an incompatible type
      Type "(grantee: IGrantable) -> Grant" cannot be assigned to type "(identity: IGrantable) -> Grant"
        Parameter name mismatch: "identity" versus "grantee"
    "grant_invoke_url" is an incompatible type
      Type "(grantee: IGrantable) -> Grant" cannot be assigned to type "(identity: IGrantable) -> Grant"
        Parameter name mismatch: "identity" versus "grantee"PylancereportGeneralTypeIssues

`IFunction`の`grant_invoke`というメソッドは`(identity: IGrantable)`という引数ですが、`Function`はこのメソッドを`(grantee: IGrantable)`という引数で実装してしまっているために、Python上では継承関係にない、即ち型に互換性がないとみなされてしまっています。

# Workaround
血反吐を吐きながら`# type: ignore`で型チェックを無視します。CDKv3とかで改善されると嬉しいですね。

[^1]: [AWS CDK Goならではの制約とお作法を考えつつハローワールドしてみた](https://dev.classmethod.jp/articles/aws_cdk_go_helloworld/)
[^2]: CDKの開発チームもこの問題を認識しているようで、2021年の時点で[Issue](https://github.com/aws/aws-cdk/issues/15584)になっています。