lulliecat

Software Design & Development

検索

可変長引数の個数はゼロもあり得るよね、という話

TypeScript大河るり(Taiga Luri)大河るり(Taiga Luri)

この記事をシェア

表題通りの話です。ふつうの話ですね。が、変なところでどはまりしたので小ネタ的にメモします。

概要

特に出し渋るほどのものではないので、結果からお話します。

1つの引数をとるある関数の型があるとします。処理はなんでもいいです。

type ArgFnT = (x: number) => number;

ArgFnT型の関数を引数にとる高階関数apを定義します。これも処理はなんでもいいです。

function ap(fn: ArgFnT): number {
    return fn(1);
}

apの引数として、ArgFnT型である関数を渡すと、型エラーが出ずなにがしかの計算が行われます。

console.log(ap((x) => x));
// OUTPUT: 1

apの引数として、2引数の関数を渡すと、型エラーが出ます。実行結果は、この関数の場合ですとyがundefinedになるので、 実行結果もundefinedになります。

console.log(ap((x, y) => x + y));
// 型エラー: 
// Argument of type '(x: any, y: any) => any' is not assignable to parameter of type 'ArgFnT'.
//  Target signature provides too few arguments. Expected 2 or more, but got 1.(2345)
// (parameter) y: any

ここまでは大丈夫だと思います。ぼくがはまったのは次です。

console.log(ap((x, ...y) => y.reduce((p, c) => p + c, x)))
// OUTPUT: 1
// 型エラーにならない

与えた関数の型はArgFnT型とほとんど同じで、ひとつの違いは、残余引数があることです。 ぼくはなんとなく、これは仮引数の名前2個書いてあるし、間違ってこう書いてあったら型エラーが出るんじゃね、 と思っていましたが、実際はエラーが出ません。 冷静に考えるとそれはそうです。残余引数は0個でもいいので、ArgFnT型のような型のサブタイプ?にもなれます。 (サブタイプは言葉が違うかも)

なので、上記のような書き方をしていると、残余引数のある関数がうっかりするっと型チェックをパスして所望の動作ではなくなる可能性があるので、気をつけましょうね、という話でした。

ことの発端

私がqrillというSSGを作成しているときの話です。 このSSGではTypeScriptを利用していて、コンポーネントは関数としてあらわされています。 コンポーネント関数を呼び出すと、HTMLのDOMに相当するオブジェクトQNodeを生成し戻り値とします。 コンポーネントのpropsは第一引数として、子要素は第二引数以降に残余引数として渡すことを想定していました。


export type Component1Props = {
    id: string;
};

function Component1(props: Component1Props, ...children: QNode[]) {
    return DivElement({ id: props.id }, ...children);
}

これはこれで想定通り動いていました。ですが、関数呼び出しがつらなるとやはり見た目が渋いなぁ、と思いまして、jsx/tsx記法を導入することにしました。

意外と簡単 JSX記法の実装

jsx記法に対応するのはそんなに難しくなくて、jsx()という関数を実装すれば事足ります。asが二か所もありますが、雑な実装なので許してください...

export type Component<T extends Attribute> = string | ComponentFn<T>;

export function jsx<T extends Attribute>(element: Component<T>, props: Partial<T> & { children?: JSXChildren }): QNode {
    const { children, ...attribute } = props;

    if (typeof element === "string") {
        return {
            tag: element as Tag,
            attribute,
            children,
        };
    }
    return element(attribute as T, ...children);
}

jsx()関数は、HTML要素やコンポーネントをDOM(この場合はQNode)に変換する関数です。 JSX記法で書かれたHTML要素やコンポーネントのインスタンス化はこの関数により制御されます。

コンポーネントを<Component1 id="id1" />という形でインスタンス化すると、そこでjsx()関数が呼ばれ、 コンポーネントからノードを表すオブジェクト(qrillの場合はQNodedという型のオブジェクト)へと変換が行われます。 小文字からはじまる従来のタグはelementに文字列が設定され、大文字からはじまるコンポーネントは、qrillの場合は関数ですので、 その関数自体がjsx()の引数elementとしてわたってきます。 qrillではこのコンポーネント関数を呼び出すことで、コンポーネントからQNodeへの変換をおこなっていました。

コンポーネントの記述も下記のようになり、すっきりしました。

function Component1(props: Component1Props, ...children: QNode[]) {
    return (
        <div id={props.id}>
            {children}
        </div>
    );
}

作法はReactっぽくしたほうがいいかも?

Reactの作法にはあまり詳しくないのですが、どうやらコンポーネントに渡す子要素もchildrenという名のPropsに入るようですね。 qrillでは別引数で分けてたので、逆スプレッド構文みたいなやつ(よくしらない)でchildrenを分離していました。 このままでもよかったんですが、別件でこの周りで型を整理しているときに、まぁasも取りたいし、仕様をReactにあわせるか、と思い下記のように修正しました。

export function jsx<T extends PropBase>(elem: Component<T>, props: T): QNode {
    if (typeof elem === "string") {
        return {
            tag: elem as Tag,
            props,
        };
    }
    return elem(props);
}

結構すっきりしましたね。asもひとつとれていい感じです。

そして、コンポーネント定義の修正忘れが生じる

あとは手作業で引数を変えていくだけ、という段階になったところで、案の定生じました、変更忘れ。 改めて、先ほどのコンポーネントを表示します。

function Component1(props: Component1Props, ...children: QNode[]) {
    return (
        <div id={props.id}>
            {children}
        </div>
    );
}

これ、以前のタイプのコンポーネントの書き方ですが、実はこれはこのまま型チェックパスしちゃうんですよね。 最初に話した原因で、最後の残余引数が[]になる呼び方になります。そうすると、本来第一引数にあった childrenが取得できず、子要素が空になってしまいました。何もレンダリングされなくなったのです。

正しくは、以下のようになります。

export type Component1Props = {
    id: string;
    children: JSXChildren;
};

function Component1({ id, children }: Component1Props) {
    return (
        <div id={id}>
            {children}
        </div>
    );
}

なんだよー型エラー出してくれよー、とか思いましたが、これは致し方ない。 そもそも修正前も型エラーでてなくて、なんじゃこりゃって思っていながらスルーしてたのが悪かったのです... でも勉強になりました!日々精進です!