✏️

"コードに型を合わせ"ながらパズルプレーヤーをTS移植している話

に公開

Web上でペンシルパズルを解くためのライブラリ"pzprjs"のTypescript移植をしています。

リポジトリはこちら。

https://github.com/smynudop/pzprts

ライブラリの他、Web Componentsの形でも提供しており、4行の<script>を書くだけであなたのサイトで使うこともできます。

元ライブラリのリポジトリはこちらです。

https://github.com/sabo2/pzprjs

ペンシルパズルとは

「紙とペンで解くことができるパズル」です。ルールを満たす盤面を推理や試行錯誤で見つけていくパズルの一種です。
数字を埋めるもの、線を引くもの、マスを塗りつぶすものなど、さまざまな種類があります。
数独やクロスワード、スリザーリンクやカックロといったパズルは、新聞や雑誌で見たことがある方も多いのではないでしょうか。多くの愛好家がおり、日夜あらたなルールのペンシルパズルが考案されています。

pzprjsについて

pzprjsはweb上でペンシルパズルを作成・解答するためのライブラリです。作成したパズルをURLとして出力でき、利用者が自作の問題をURLの形でSNSやブログなどに公開できるのが大きな特徴です。また、アンドゥ・リドゥや「仮置き機能」なども充実しており、パズルを解くうえで欠かせない試行錯誤を強力にサポートしてくれます。

以下のサイトで無料で利用することができます。

http://pzv.jp

また、pzprjsのフォークで、pzv.jpにないパズル種も遊べる以下のサイトもメジャーです。

https://puzz.link
https://pzprxs.vercel.app

数々のペンシルパズルの書籍や雑誌を発売しているニコリ社が運営するポータルサイト「パズコミュ」でも、プレーヤーにpzprjsのフォーク(厳密にはフォークのフォーク?)が使われています。インターネット上のペンシルパズルにおいて欠かせないツール、と言ってよいでしょう。

余談になりますが、「パズコミュ」ではニコリ作家が作ったペンシルパズルが無料で遊べます。お好きな方は是非リンク先に会員登録して、問題を解いてみてくださいね。

https://puzzle.nikoli.com

さて、宣伝はこれくらいにして……
ペンシルパズルには実にさまざまな種類がありますが、pzprjsでは、さまざまなルールのパズルを拡張の形で定義することができます。
たとえば、「チョコバナナ」というパズルの定義ファイルは以下のようになっています。(厳密にはチョコバナナはpzprjsのフォークであるrobx/pzprjsに実装されているパズルですが、短く例示に向いているので掲載します)

長いので折りたたみ
(function(pidlist, classbase) {
	if (typeof module === "object" && module.exports) {
		module.exports = [pidlist, classbase];
	} else {
		pzpr.classmgr.makeCustom(pidlist, classbase);
	}
})(["cbanana"], {
	MouseEvent: {
		use: true,
		inputModes: {
			edit: ["number", "clear"],
			play: ["shade", "unshade"]
		},
		autoedit_func: "qnum",
		autoplay_func: "cell"
	},

	KeyEvent: {
		enablemake: true
	},

	Cell: {
		maxnum: function() {
			return this.board.cols * this.board.rows;
		}
	},

	AreaShadeGraph: {
		enabled: true
	},
	AreaUnshadeGraph: {
		enabled: true
	},

	Graphic: {
		gridcolor_type: "DARK",

		enablebcolor: true,
		bgcellcolor_func: "qsub1",

		paint: function() {
			this.drawBGCells();
			this.drawShadedCells();
			this.drawGrid();

			this.drawQuesNumbers();

			this.drawChassis();

			this.drawTarget();
		}
	},

	Encode: {
		decodePzpr: function(type) {
			this.decodeNumber16();
		},
		encodePzpr: function(type) {
			this.encodeNumber16();
		}
	},
	FileIO: {
		decodeData: function() {
			this.decodeCellQnum();
			this.decodeCellAns();
		},
		encodeData: function() {
			this.encodeCellQnum();
			this.encodeCellAns();
		}
	},

	AnsCheck: {
		checklist: [
			"checkShadeRect",
			"checkUnshadeNotRect",
			"checkNumberSize",
			"doneShadingDecided"
		],

		checkNumberSize: function() {
			for (var i = 0; i < this.board.cell.length; i++) {
				var cell = this.board.cell[i];
				var qnum = cell.qnum;
				if (qnum <= 0) {
					continue;
				}

				var block = cell.isShade() ? cell.sblk : cell.ublk;
				var d = block.clist.length;

				if (d !== qnum) {
					this.failcode.add("bkSizeNe");
					if (this.checkOnly) {
						return;
					}
					block.clist.seterr(1);
				}
			}
		},

		checkShadeRect: function() {
			this.checkAllArea(
				this.board.sblkmgr,
				function(w, h, a, n) {
					return w * h === a;
				},
				"csNotRect"
			);
		},

		checkUnshadeNotRect: function() {
			this.checkAllArea(
				this.board.ublkmgr,
				function(w, h, a, n) {
					return w * h !== a;
				},
				"cuRect"
			);
		}
	}
});

{[クラス名}:[拡張], [クラス名]:[拡張]...}という形で、ベースのクラスの拡張をする形式になっています。

移植をはじめた経緯

pzprjsはインターネットペンシルパズル界隈(?)にはなくてはならない、偉大なプロジェクトです。数々のフォークもあるのですが、設計思想の型との相性の悪さからか、未だにTypescript化されていません。
今後ますます使われるであろうライブラリであることを考えると、Typescriptに移植する価値があると思いました。
また、自分の移植が見向きもされなかったとしても、それを糧にだれかがもっと良い移植プロジェクトを作ってくれるかもしれないという目論見もありました。

……と、大層なお題目を並べてしまいましたが、一番は、そこにjsで書かれたプロジェクトがあり、typescriptに移植してみたかったからです。
noImplicitAnyをtrueにして発生した大量のエラーに、腕まくりして型つけていくのちょっと楽しくないですか?……え?そうでもない?

移植の方針

移植にあたっては、型の恩恵を受けたモダンな設計にすることを念頭に、以下の方針で作業を行っています。

  1. ES Modulesへの書き直し
    • 元ライブラリはファイル単体を即時関数で囲いつつ、Gruntでファイルを結合し、minifyしてバンドルするという設計になっていました。流石に2025年にこれはつらいので、全体をES Modulesで書きなおします。ビルドにはViteを使います。
  2. class構文の使用
    • 元のライブラリはオブジェクト指向で書かれています。Board,Cellなどのオブジェクトや、Encode, FileIOなどの機能群などがクラスとして定義されています。そしてこのライブラリは、 class が導入されたES2015以前に書かれているので、クラスの機能がfunctionを用いて書かれています。これらを class を使って書き換えます。
  3. 柔軟な拡張性の維持
    • pzprjsの大きな特徴である拡張性を維持したいと考えました。移植する上で工数が少ないことは大事です。さきほど掲載したような拡張ファイルが、ほぼそのまま使えるような状態を目指します。

型に合わせるんじゃない、コードに型を合わせるんだ

やはりというか、ネックになったのは各ルールの移植でした。
ベースとなるクラスを、各パズルごとに拡張するという性質からすると、自然なのは継承で各クラスを書き直すことです。ですがこの方法では結構な量の書き直しが発生してしまい、移植コストが上がってしまいます。ルールの数が膨大なうえに、そもそも型付けの作業もあるため、なるべく移植コストは下げたいです。
そこで、最大限元の書き方を活かしながら移植できるような方法を模索した結果、次のような方法をとることにしました。

  • 拡張の記述側では型定義を駆使し、拡張メソッド部分でも型が効くようにする。
  • 実行時にObject.assign でクラスを動的に拡張する。

先程の「チョコバナナ」の定義から一部抜粋します。

	Encode: {
		decodePzpr: function(type) {
			this.decodeNumber16();
		},
		encodePzpr: function(type) {
			this.encodeNumber16();
		}
	},

これは、EncodeというクラスのdecodePzpr, encodePzprという関数をオーバーライドしています。これに型を付けるためには、this に適切な型を与えてあげる必要があります。
このような場合のために、typescriptには ThisTypeというUtility Typesがあります。この場合で言えば、ThisType<Encode>と書くことで、ここでいうthisはEncodeというクラスのことだよ、と教えてあげることができます。

また、この方法で定義の拡張も可能です。ThisType<Encode & EncodeExtend>と書けば、EncodeをEncodeExtendで拡張したものが thisだと教えることもできます。更に、&の効果で、メンバの型が一致しない拡張をしようとした場合に、エラーとして知ることができるという効果もあります。

そしてさらに、これらの型を自動で推論させたいです。元のオブジェクトのままでは型注釈を書かねばなりませんが、関数の形にすることで型推論させるテクニックがあります。そこで、createVarietyという関数を作り、これに元のオブジェクトを通すだけで型推論してくれるような型の仕組みを構築することにしました。

ご存じの方もいると思いますが、VueのdefineComponentも同じような方法でthisの拡張を実現しています。

そうして生まれた createVariety 周りの型定義がこちらです。

type ExtendClass<TBase, TExtend> = TExtend & ThisType<TBase & TExtend>
export type VarityOption<
    CellExtend,
    BorderExtend,
    BoardExtend,
    BoardExecExtend,
    MouseExtend,
    KeyExtend,
    EncodeExtend,
    FileIOExtend,
    GraphicExtend,
    AnsCheckExtend,
    OperationManagerExtend,
    GraphComponentExtend
> = VarityOptionInner<
    Board<
        Cell & CellExtend,
        Cross,
        Border & BorderExtend,
        EXCell,
        GraphComponent<Cell & CellExtend> & GraphComponentExtend
    > & BoardExtend,
    CellExtend,
    BorderExtend,
    BoardExtend,
    BoardExecExtend,
    MouseExtend,
    KeyExtend,
    EncodeExtend,
    FileIOExtend,
    GraphicExtend,
    AnsCheckExtend,
    OperationManagerExtend,
    GraphComponentExtend
>

export type VarityOptionInner<
    TBoard extends Board,
    CellExtend,
    BorderExtend,
    BoardExtend,
    BoardExecExtend,
    MouseExtend,
    KeyExtend,
    EncodeExtend,
    FileIOExtend,
    GraphicExtend,
    AnsCheckExtend,
    OperationManagerExtend,
    GraphComponentExtend //extends GraphComponentOption
> = {
    pid?: string
    Cell?: ExtendClass<Cell, CellExtend>,
    Cross?: CrossOption,
    Border?: ExtendClass<Border<TBoard>, BorderExtend>
    MouseEvent: ExtendClass<MouseEvent1<TBoard>, MouseExtend>,
    KeyEvent?: ExtendClass<KeyEvent<TBoard>, KeyExtend>,
    EXCell?: EXCellOption & { [key: string]: any } & ThisType<EXCell>
    Board?: ExtendClass<TBoard, BoardExtend>
    BoardExec?: ExtendClass<BoardExec<TBoard>, BoardExecExtend>
    TargetCursor?: { [key: string]: any } & ThisType<TargetCursor<TBoard>>
    GraphComponent?: ExtendClass<GraphComponent, GraphComponentExtend>
    LineGraph?: LineGraphOption & ThisType<LineGraph>
    AreaShadeGraph?: AreaShadeGraphOption
    AreaUnshadeGraph?: AreaUnshadeGraphOption
    AreaRoomGraph?: AreaRoomGraphOption<GraphComponent<Cell & CellExtend> & GraphComponentExtend, TBoard> & ThisType<AreaRoomGraph<GraphComponent & GraphComponentExtend, TBoard>>
    Graphic: ExtendClass<Graphic<TBoard>, GraphicExtend>,
    Encode: (ExtendClass<Encode<TBoard>, EncodeExtend>) | Converter[]
    FileIO: ExtendClass<FileIO<TBoard>, FileIOExtend>
    AnsCheck: ExtendClass<AnsCheck<TBoard>, AnsCheckExtend>
    FailCode?: { [key: string]: [string, string] }
    OperationManager?: ExtendClass<OperationManager, OperationManagerExtend>
}

export type VarietyAnyOption = VarityOption<any, any, any, any, any, any, any, any, any, any, any, any>

export const createVariety = <
    CellExtend extends CellOption,
    BorderExtend extends BorderOption,
    BoardExtend extends BoardOption,
    BoardExecExtend extends BoardExecOption,
    MouseExtend extends MouseEventOption,
    KeyExtend extends KeyEventOption,
    EncodeExtend extends EncodeOption,
    FileIOExtend extends FileIOOption,
    GraphicExtend extends GraphicOption<Cell & CellExtend>,
    AnsCheckExtend extends AnsCheckOption,
    OperationManagerExtend extends OperationManagerOption,
    GraphComponentExtend //extends GraphComponentOption
>(varietyOption: VarityOption<
    CellExtend,
    BorderExtend,
    BoardExtend,
    BoardExecExtend,
    MouseExtend,
    KeyExtend,
    EncodeExtend,
    FileIOExtend,
    GraphicExtend,
    AnsCheckExtend,
    OperationManagerExtend,
    GraphComponentExtend
>): new (option?: IConfig) => Puzzle => {
    return class extends Puzzle {
        constructor(option?: IConfig) {
            super({ ...option, pid: varietyOption.pid }, varietyOption)
        }
    }
}

これで9割がた、型を推論してくれます。
こんなに複雑な型を書いても動くtypescriptはシンプルにすごいなと思います(語彙力のない感想)。しかし、ここまで複雑な型だと推論コストも馬鹿にならない気がします。
さすがに推論に無理があるようで、値を返すfunctionは返り値の型を明示しないと推論してくれません。あと、副作用というか、型エラーが読む気にならないくらい複雑です……。

しかも完璧じゃない

しかし、これでも完璧ではないという始末で、一部はas any@ts-ignoreでしのいでいる現状です。
元ライブラリが型を意識して設計されていないという部分もあるのですが、たとえば Board という盤面を司るクラスと、Cellというマス目を司るクラスが相互にお互いの型を知らないといけず、そのような部分が推論しきれない、など、型の限界もあります。

おわりに

完璧ではないとはいえ、型をつけることにより、エディタ上でコードジャンプができるなど、人間側の作業効率は劇的に上がっています。元ライブラリのバグらしきメソッドを発見するなどの副次効果もありました。
将来的には型ファーストで根本的に設計を見直すのがよいのでしょうが、そのような手入れはfull Typescript化を達成してからやるべきだと感じており、移植途上においては現状の設計は現実的な解として及第点なのではないか、と勝手に思っています。
型システムのメリットに、型が設計をある程度決めてくれる、ということがありますが、「型を現状に合わせる」こともできるtypescriptは、かなり懐の深い言語であると感じます。

@udop/penpa-playerはcontributer、もしくはもっと良いTypescript移植プロジェクトをいつでも募集しています。

Discussion