mini-typescript に string literal を追加してみた
こんにちは 人材育成室 育成メンバーチームで 研修中の はすと です。
これまで TypeScript コンパイラの教材プロジェクト mini-typescript に let・interface を追加しながら、コンパイラの各フェーズに触れてきました。今回は string literal(文字列リテラル)の追加 をしてみます。
前回の記事: mini-typescript に interface を追加して宣言マージを学ぶ
今回のゴールは、以下のように文字列リテラルを扱えるようにすることです。
// 入力 (TS)
var greeting: string = "hello"
// 出力 (JS)
var greeting = "hello"
実は mini-typescript には、初めから string 型が定義されているので、var x: string = 1 と書けばきちんと型エラーになります。にもかかわらず var x: string = "hello" もエラーになります。これは、"hello" という 文字列の「値」を作る手段が無い ためです。
var x: string = 1 // 型エラーになる(string 型は定義済み)
var x: string = "hello" // でもこれもエラー。"hello" を値として作れない
interface 編の見どころが「宣言マージ」だったのに対し、今回の見どころは、コンパイラが「型」と「値」を別の経路で扱っていることです。string という型は最初から定義されているのに、その型を持つ値を作る手段がありません。そこに、string literal を追加すると、この2つの経路がつながります。
また、もう一つの見どころは Token と Node をどこで分けるか という設計判断です。詳しくはステップ①で触れます。
環境
- Node.js v24.13.0
- pnpm 10.27.0
- TypeScript 6.0.3
- mini-typescript(
interface追加の続きから)
セットアップは pnpm install && pnpm build だけです。
今回のスコープ
文字列リテラルを真面目にやると、エスケープシーケンス("\n")、シングルクォート、テンプレートリテラル(`...`)、文字列結合("a" + "b")まで欲しくなります。しかし、今回のゴールは「string 型に対応する値を作れるようにする」ことなので、スコープは最小にします。
- やること: ダブルクォート・シングルクォート文字列
"..."/'...'のリテラル、その型推論(string)、JS への出力 - やらないこと: エスケープシーケンス、テンプレートリテラル、文字列結合
この線引きで、let・interface 編と同じ「1機能・コンパイラを縦に通す」粒度に収まります。
string literal を追加する手順
今回触るのは次の4フェーズです。let 編は5フェーズ全部、interface 編は型モデルにも手を入れましたが、今回は Bind と Transform を触りません。新しい「宣言」でも「型定義」でもなく、既存の仕組み(stringType)を つなげるだけ です。
| フェーズ | やること |
|---|---|
| Lex | "..." / '...' を StringLiteral トークンとして認識 |
| Parse | StringLiteral トークンを Node.StringLiteral ノードへ |
| Check | Node.StringLiteral なら stringType を返す |
| Emit | Node.StringLiteral ならクォートで囲んで出力 |
実際に編集するのは、この4フェーズに加えて土台となる types.ts(Token/Node enum と型定義)です。types.ts はフェーズではなく土台なので表からは外しています。
① types.ts: Token と Node の拡張
まず全フェーズの土台となる types.ts を整えます。
Token enum に StringLiteral を追加します。
export enum Token {
// ...
Literal,
StringLiteral, // 追加
Identifier,
// ...
}
次に Node enum にも StringLiteral を追加し、対応する型を定義します。
export enum Node {
Identifier,
Literal,
StringLiteral, // 追加
Assignment,
// ...
}
export type Literal = Location & {
kind: Node.Literal
value: number // number のまま(変更なし)
}
export type StringLiteral = Location & { // 追加
kind: Node.StringLiteral
value: string
}
export type Expression = Identifier | Literal | StringLiteral | Assignment // StringLiteral を追加
Node の方は、当初は共通のまま value: number | string に広げる案でしたが、その場合、check.ts や emit.ts の switch で value が数値か文字列かをランタイムで判定する必要があります(typeof expression.value === 'string' のような形)。一方、Node を分けると case Node.StringLiteral と書くだけで、TypeScript の型システムが「 value は必ず string」と静的に保証してくれます。
実際の TypeScript コンパイラも、SyntaxKind.NumericLiteral と SyntaxKind.StringLiteral を別の Node として定義しています。
Token と Node で「分ける」意味が違う
Token は「ソース上の見た目の区別」です。123 と "hello" はソース上の書き方が違うので、Lex の段階で別トークンとして区別するのは自然です。
Node は「AST 上の意味の区別」です。今回 Node も分けたのは、型の違い(value: number か value: string か)をコンパイラが静的に追跡できるようにするためです。チェッカーや emit が typeof でランタイム判定をするより、ノードの種別で静的に分岐する方が型安全です。
② lex.ts: 文字列のスキャン
数値リテラルのスキャンとの対比で実装します。
- 数値:
[0-9]で始まる →[0-9]が続く限り読み進める - 文字列:
"または'で始まる → 同じ文字が来るまで読み進める → 閉じクォートで終了
else if (s.charAt(pos) === '"' || s.charAt(pos) === "'") {
const quote = s.charAt(pos) // 開きクォートの種類を記憶
pos++ // 開きクォートを飛ばす
scanForward(c => c !== quote) // 同じ文字が来るまで読み進める
pos++ // 閉じクォートを消費
text = s.slice(start, pos) // "hello" / 'hello' ごと保持(クォート込み)
token = Token.StringLiteral
}
数値スキャンとの違いで注意が必要なのは、閉じクォートの消費です。数値の場合は [0-9] 以外の文字が来た時点で自然にスキャンが止まります。文字列の場合は閉じクォートが終端記号なので、scanForward が止まった後に pos++ で明示的に消費しないと、閉じクォートが次のトークンに残ってしまいます。
次に、lexAll の switch にも case を追加して、text が出力されるようにします。
case Token.Literal:
case Token.StringLiteral: // 追加
tokens.push({ token: t, text: lexer.text() })
break
動作確認: src/test.ts の lexTests にダブル・シングル両方を追加してテストを実行します。
"stringLiteralLex": `"hello"`,
"singleQuoteLex": `'hello'`,
pnpm test -- --write
ベースラインに記録された結果がこちらです。
// stringLiteralLex
[["StringLiteral", "\"hello\""]]
// singleQuoteLex
[["StringLiteral", "'hello'"]]
無事にどちらも "StringLiteral" トークンとして認識されています。次はこれを AST に組み立てます。
③ parse.ts: StringLiteral トークンから Node.StringLiteral へ
parseIdentifierOrLiteral() に分岐を追加します。数値の分岐の直後に置きます。
else if (tryParseToken(Token.Literal)) {
return { kind: Node.Literal, value: +lexer.text(), pos }
}
else if (tryParseToken(Token.StringLiteral)) { // 追加
return { kind: Node.StringLiteral, value: lexer.text().slice(1, -1), pos }
}
lexer.text() は "hello" というクォート込みの文字列を返します。slice(1, -1) で前後のクォートを取り除いて hello だけを value に格納します。数値の場合は +lexer.text() で文字列 "123" を数値 123 に変換していたのと対称的です。
動作確認: tests/stringLiteral.ts を作成してテストします。
var greeting = "hello"
pnpm test -- --write
tree ベースラインに Node.StringLiteral が記録されました。
{
"kind": "Var",
"name": { "kind": "Identifier", "text": "greeting" },
"init": {
"kind": "StringLiteral",
"value": "hello"
}
}
init の kind が "StringLiteral" になり、value がクォート無しの "hello" になっています。パーサーはここまでです。次はこのノードに型を付けます。
④ check.ts: Node.StringLiteral に stringType を返す
checkExpression の switch に Node.StringLiteral の case を追加します。
case Node.Literal:
return numberType
case Node.StringLiteral: // 追加
return stringType
これで stringType がついに「値から推論される型」として初めて機能します。これまでの stringType は checkType で型注釈 string を解釈するためだけに存在していましたが、ここで「文字列リテラルの型」としての役割も加わります。
動作確認: 2つのテストケースで確かめます。
// tests/stringTypedVar.ts
var s: string = "hello" // エラーなし
// tests/stringTypeError.ts
var s: number = "hello" // 型エラー
pnpm test -- --write
stringTypedVar.errors.baseline は空(エラーなし):
[]
stringTypeError.errors.baseline には型エラーが記録されました:
[
{
"pos": 23,
"message": "Cannot assign initialiser of type 'string' to variable with declared type 'number'."
}
]
string と number が正しく区別されています。残りは JS として出力するだけです。
⑤ emit.ts: クォート付きで出力
emitExpression の switch に Node.StringLiteral の case を追加します。
case Node.Literal:
return "" + expression.value
case Node.StringLiteral: // 追加
return `"${expression.value}"`
Literal(数値)は "" + expression.value で文字列化するだけ。StringLiteral はクォートを復元して出力します。パーサーで slice(1, -1) により取り除いたクォートを、ここで再び付けています。
動作確認: tests/stringLiteral.ts の JS ベースラインを確認します。
pnpm test
"var greeting = \"hello\""
クォート付きで出力されています。All tests passed が確認できたら完成です。
テストとベースライン
追加したテストケースと検証ポイントをまとめます。lexTests[...] は src/test.ts 内に直接書く Lex 単体のテスト、tests/xxx.ts は tests/ ディレクトリにあるコンパイル全体のテストです。
| テストケース | 内容 | 確認内容 |
|---|---|---|
lexTests["stringLiteralLex"] |
"hello" |
ダブルクォートが StringLiteral トークンになる |
lexTests["singleQuoteLex"] |
'hello' |
シングルクォートも StringLiteral トークンになる |
tests/stringLiteral.ts |
var greeting = "hello" |
tree に Node.StringLiteral、JS にクォート付き出力 |
tests/stringTypedVar.ts |
var s: string = "hello" |
型注釈 string と整合してエラーなし |
tests/stringTypeError.ts |
var s: number = "hello" |
string を number に代入しようとして型エラー |
tests/singleQuoteLiteral.ts |
var s: string = 'hello' |
シングルクォート入力が JS ではダブルクォートで出力される |
まとめ
string literal の追加を通じて、let・interface 編では気づかなかったコンパイラの構造が見えてきました。
stringType はすでに定義済みで、var x: number = "hello" のような書き方できちんと型エラーが出ます。そのため、てっきり JS への出力もそのまま通るものだと思っていました。ところが実際は parse の段階でエラーになります。これは面白い点で、string という型は型システム側にあるのに、それに対応する値(リテラル)を作る手段が存在しないだけだったのです。普段 TypeScript を書いているとなんとなく「型と値はつながっているもの」と思ってしまいますが、コンパイラ内部では型名前空間と値名前空間が完全に別の経路で管理されているため、「片側だけ機能する」という状態が成立します。let では値だけ、interface では型だけを追加していたため、あまり意識しない部分でした。しかし今回 string literal の追加を行ったことで、両方の経路を同時に意識できる機会になりました。
次の練習問題の候補としては、オブジェクトリテラル({ x: 1 })や関数宣言があります。interface 編で「オブジェクト型」は作りましたが、「オブジェクト値」はまだ存在しません。ここに手を入れると、今回の「型と値の別経路」がより鮮明に見えてきそうです。




