タグ付きテンプレートと型チェックによって、文字列の安全性を確保する


最近また暑くなってしまった今日この頃、みなさんごきげんよう。最新JavaScript開発~ES2017対応モダンプログラミング (技術書典シリーズ(NextPublishing))という本が発売中です。(レビューにある件のうちツールの方は最低限修正したんですが、コードサンプルについては現在修正中です)

さて、



でJSの型安全性についての話があったんですが、「文字列型に対する型システム」という話があって、それってタグ付きテンプレートリテラルと型チェックで簡単に実現できるよなーと思ったので今回そこらへんの実験を行ってみました。

タグ付きテンプレートリテラル

テンプレートリテラルというのは、RubyやPHPなど他の言語でも見られる文字列の中にコードや変数を展開するための機能です。

const id = 10
console.log(`id: ${id}`) // --> id: 10

で、タグ付きテンプレートリテラルというのは、Scalaの文字列補間子のようなものです。

const hoge = (s, ...args) => s.map((v, i) => i < args.length ? `${v}${args[i]}` : v).join('')
const id = 10
console.log(hoge`id: ${id}`) // --> id: 10

テンプレートリテラルの文字列補完の時にタグ指定した関数が、特定のルールによってよびだされるのです。それを処理した戻り値がテンプレートリテラルの最終的な演算結果になります。この時、戻り値は文字列以外の任意の型を使うことも出来ます。

さて、この事例だと引数に対して何も処理をしてないので意味がないんですが、処理を挟めば意味がでてきます。

TypeScriptと組み合わせる

hogeタグの付いたテンプレートリテラルがHoge型を返すとしたら、文字列補完を型で強制する事ができるようになるのではないか?ということでTypeScriptと組み合わせてみましょう。

export default class SQL {
    query: string

    private constructor(query: string) {
        this.query = query
    }

    static escape(s: string) {
        // なんかすごいエスケープ処理
        return s
    }

    static sql(s: TemplateStringsArray, ...args: any[]) {
        const query = s.map((v, i) => i < args.length ? `${v}${SQL.escape(args[i].toString())}` : v).join('')
        return new SQL(query)
    }
}

export const sql = SQL.sql

ここではSQL型とsql補間子を定義しています。SQL型はconstructorがprivateなので、new SQL(query)のように直接インスタンス生成はできません。sql補間子経由じゃないと生成できないのです。

import SQL, {sql} from './sql'

class DB {
    query(s: SQL) {
        console.log(s.query)
    }
}

const db = new DB()
const id = 10
db.query(sql`select hoge from fuga where id = ${id}`)

このような形で使います。DBのqueryメソッドには、sql補間子を使うことが強要されます。

db.query('select hoge from fuga where id = 10')
// --> Argument of type '"......"' is not assignable to parameter of type 'SQL'.

このように直接文字列を渡すとエラーが出ます。

今の時代、静的型のチェックできる言語であれば、query(s: string)みたいな定義はダサいと思います。SQL, URL, HTMLなどなど、ちゃんと型を定義して、文字列の安全性を確保すべきでしょう。

追記: より高度な埋め込み

というツッコミをいただきました。これはSymbol型でいけますね。

export default class SQL {
    static readonly ASC: symbol = Symbol('ASC')
    static readonly DESC: symbol = Symbol('DESC')
    // 他色々キーワードとか

    private templateStrings: TemplateStringsArray
    private templateArgs: any[]

    private constructor(templateString: TemplateStringsArray, templateArgs: any[]) {
        this.templateStrings = templateString
        this.templateArgs = templateArgs
    }

    public getQuery() {
        const extract = (v, i) => `${v}${SQL.escape(this.templateArgs[i])}`
        return this.templateStrings.map((v, i) => i < this.templateArgs.length ? extract(v, i) : v).join('')
    }

    private static escape(value: any) {
        switch (value) {
            case SQL.ASC: 
                return 'ASC'
            case SQL.DESC:
                return 'DESC'
            default: {
                // 型によってあれこれ処理を変えたり
                return value
            }
        }
    }

    static sql(templateStrings: TemplateStringsArray, ...templateArgs: any[]) {
        return new SQL(templateStrings, templateArgs)
    }
}

export const sql = SQL.sql

ASCとかDESCのキーワード生成だけじゃなくてORDER BY ASCとか自体を生成するなんてのもやろうと思えば可能ですね (やりすぎな気がしなくもない)。

他にもプレースホルダに対応してみたり、MySQL, PostgreSQL, Oracleの挙動を吸収できるようにしたりとか、色々可能かなーと思います。

(今回の主題とは関係なくTypeScriptの話ですが) 罠が一つ

class Hoge {

}

const hoge = (arg: Hoge) => console.log(arg)

hoge('string is a Hoge??!?!?!')

実は、TypeScriptでは、メンバー・メソッドを定義してない型は、string型と互換性があると解釈されてしまいます。最初ハマりました…。

class Hoge {
    dummy() {

    }
}

const hoge = (arg: Hoge) => console.log(arg)

hoge('string is not a Hoge') // エラー

ダミーでもなんでもいいから、メンバーやメソッドがあれば違う型と見なされます。アヒルがガーっと鳴けばシステムはTypeScriptでも継承されているようです。うーん、interfaceの意味がないやん…。