본문 바로가기

FrontEnd

타입스크립트의 타입 시스템으로 틱택토(Tic Tac Toe) 구현하기

반응형

원문 보기

 

Type System Game Engines

Just for fun, what if we crafted a board game purely within TypeScript's logical type system?

blog.joshuakgoldberg.com

영상 버전 TSConf 2020

 

TSConf 2021

In its 4th year, the TSConf team is excited to organize another stellar conference (Virtually. Thanks, Covid.) that will delight and entertain even the most curmudgeonly developer!

tsconf.io

템플릿 문자열 리터럴을 이용해 Tic Tac Toe 게임을 시뮬레이션하고, 게임의 승자가 누구인지 알아내 봅시다.

type Winner = TicTacToe<`
    X 1 1
    O 2 2
    X 2 0
    O 0 2
    X 1 0
    O 0 0
    X 1 2
`>;

조건부 제네릭

타입스크크립트 공식문서 : 조건부 타입

다른 타입의 특성을 기반으로 타입을 생성하는 기술인 '조건부 제네릭'입니다.
조건부 타입은 다른 타입을 파라미터로 받아 원래 타입을 기반으로 새 타입을 리턴합니다.
 

예제 : 가위바위보

type RockMatchup<Opponent> = Opponent extends "Rock"
    ? "Draw"
    : Opponent extends "Paper"
    ? "Loss"
    : "Win";
type Result = RockMatchup<"Rock">;
// 'Draw'
 
재미있는 사실: 타입은 둘 이상의 제네릭을 받을 수 있습니다.

9개의 가능한 결과 각각에 대해 중첩 검사를 사용하여 두 플레이어의 경기 결과를 적용합니다.
type RockPaperScissors<Player, Opponent> =
    // Player is Rock
    Player extends "Rock"
        ? Opponent extends "Rock"
            ? "Draw"
            : Opponent extends "Paper"
            ? "Loss"
            : "Win"
        : // Player is Paper
        Player extends "Paper"
        ? Opponent extends "Rock"
            ? "Win"
            : Opponent extends "Paper"
            ? "Draw"
            : "Loss"
        : // Player is Scissors
        Opponent extends "Rock"
        ? "Loss"
        : Opponent extends "Paper"
        ? "Win"
        : "Draw";

타입 멤버

👉 공식 문서 : 인덱스 타입

타입 시스템의 keyof 연산자는 키가 필요한 타입을 취하여 해당 키의 Union(|)을 뱉어냅니다.

어떠한 타입이 다른 타입의 키로 알려진 경우 키를 통해 멤버를 검색할 수 있습니다.

 

모든 Tic Tac Toe 경기 결과를 큰 타입 시스템 객체로 선언하고,

keyof를 사용하여 해당 경기에 대한 매치업을 찾을 수 있습니다.

type Matchups = {
    Rock: {
        Paper: "Loss";
        Scissors: "Win";
    };
    Paper: {
        Rock: "Win";
        Scissors: "Loss";
    };
    Scissors: {
        Rocker: "Loss";
        Paper: "Win";
    };
};

type RockPaperScissors<
    Player extends keyof Matchups,
    Opponent extends keyof Matchups
> = Opponent extends keyof Matchups[Player]
    ? Matchups[Player][Opponent]
    : "Draw";
좀 더 강화된 RockPaperScissors 타입은 Player Opponent를 인덱스로 검색하여, 둘 모두 '바위', '종이' 또는 '가위'와 같은 매치업의 키 중 하나여야 합니다.
  • Opponent 타입이 Matchups[Player]의 키 중 하나면 해당 값 (Matchups[Player][Opponent])을 리턴합니다.
  • 아니면 비깁니다. ("Draw")
type Result = RockPaperScissors<"Paper", "Scissors">; // 'Loss'
extends 키워드는 타입 시스템에 제네릭 타입 파라미터특정 타입이거나 또는 더 구체적인 버전이어야 함을 알려줍니다.

튜플 타입

👉  타입스크립트 공식 문서 : 튜플 타입

튜플 타입을 이용해 게임 보드를 선언합니다.

튜플 타입은 고정길이 배열의 정해진 위치에 정해진 타입이 오는 타입입니다.

type Cell = " " | "X" | "O";
type TicTacToeBoard = [
    [Cell, Cell, Cell],
    [Cell, Cell, Cell],
    [Cell, Cell, Cell]
];
  • cell은 빈 문자열(아직 배치되지 않음), 'X' 또는 'O'와 같이 게임 보드에 있는 모든 슬롯의 내용입니다.
  • TicTacToeBoard는 튜플의 튜플이며 3x3 보드의 그리드를 나타냅니다.

승리 조건

Cell 및 TicTacToeBoard를 사용하여 타입 시스템에서 조건부 Victory 타입을 생성할 수 있습니다.
이 타입은 플레이어와 보드 타입를 받아 승리 조건을 검사 후, 해당 플레이어가 해당 보드 조건에서 승리하는지를 판별합니다.
(대각선, 수평 또는 수직 조건)
type Victory<Player, Board> = Board extends WinningBoard<Player> ? true : false;

type WinningBoard<Player> =
    | DiagonalVictory<Player>
    | HorizontalVictory<Player>
    | VerticalVictory<Player>;
대각선 승리 조건 :  대각선 중 하나가 해당 플레이어의 조각으로만 채워져야 합니다.
셀 스팟은 무엇(o,x,'')이든 채울 수 있지만 플레이어 스팟은 플레이어의 조각으로만 채워야 합니다.
type DiagonalVictory<Player> =
    | [[Player, any, any], [any, Player, any], [any, any, Player]]
    | [[any, any, Player], [any, Player, any], [Player, any, any]];

 

수평 승리 조건 : 세 줄의 수평 줄 중 하나가 플레이어의 조각으로만 채워져야 합니다.

 
type HorizontalVictory<Player> =
    | [[Player, Player, Player], [any, any, any], [any, any, any]]
    | [[any, any, any], [Player, Player, Player], [any, any, any]]
    | [[any, any, any], [any, any, any], [Player, Player, Player]];

수작 승리 조건 : 세 줄의 수직 줄 중 하나가 플레이어의 조각으로만 채워져야 합니다.

type VerticalVictory<Player> =
    | [[Player, any, any], [Player, any, any], [Player, any, any]]
    | [[any, Player, any], [any, Player, any], [any, Player, any]]
    | [[any, any, Player], [any, any, Player], [any, any, Player]];

WinningBoard 타입을 사용하여 빈 보드에 대한 WinAtStart 검사가 false임을 알 수 있습니다.

즉 시작하자마자 이길 순 없습니다.

type StartingBoard = [[" ", " ", " "], [" ", " ", " "], [" ", " ", " "]];

type WinAtStart = Victory<"X", StartingBoard>;
// false
 
몇 개의 조각을 추가한 후 해당 보드에 대해 대각선 승리를 검사하면 결과가 참이 됩니다. 성공입니다!.
type HowAboutNow = Victory<
    "O",
    [["O", " ", "X"], ["X", "O", " "], [" ", "X", "O"]]
>;
// true
 
Wnner 타입 : 조건을 한 단계 더 높이면 어느 플레이어가 승리하는지 확인할 수 있습니다.
type Winner<Board> = Victory<"X", Board> extends true
    ? "X"
    : Victory<"O", Board> extends true
    ? "O"
    : " ";

type StartingWinner = Winner<StartingBoard>;
// ' '

type WinnerNow = Winner<[["O", " ", "X"], ["X", "O", " "], [" ", "X", "O"]]>;
// 'O'

 

Mapped Types

👉 타입스크립트 공식 문서 : Mapped Types

Mapped Types(매핑된 타입)은 keyof 연산자와 같은 키 목록을 가져와 새로운 타입 멤버를 만듭니다.

예를 들어 매핑된 타입을 사용하여 좀 더 동적인 방식으로 Tic Tac Toe 보드를 선언할 수 있습니다.
type TicTacToeBoard = {
    [Row in 0 | 1 | 2]: {
        [Column in 0 | 1 | 2]: Cell;
    };
};
 
사실, 이 타입은 객체를 나타앱니다.
// Like this, but for all the board descriptions...
{
    0: { 0: Player, 1: any, 2: any },
    1: { 0: any, 1: Player, 2: any },
    2: { 0: any, 1: any, 2: Player },
}

 

보드에 조각을 배치하기

Tic Tac Toe 보드에 조각을 배치해 봅시다.
아래 FirstMove 타입의 의도 : "게임 시작 시 비어있는 보드에 행 0 / 열 1에 'X'를 배치"를 고려하면,
조건부 매핑 타입을 사용하여 기존 보드 튜플을 파라미터로 받아 수정된 보드를 출력할 수 있어야 합니다.
type FirstMove = ["X", 0, 1];

type AfterFirstMove = [[" ", "X", " "], [" ", " ", " "], [" ", " ", " "]];

ReplaceInBoard는 4개의 입력을 받습니다.

  • Board: 이전 보드
  • Replacement: 보드에 배치할 새로운 조각
  • RowPlace: 배치할 행 번호
  • ColumnPlace: 배치할 열 번호

만약 [행,열] 번호와 일치하면 ? 새로운 조각을 배치합니다 : 아니면 그냥 둡니다.

type ReplaceInBoard<
    Board extends TicTacToeBoard,
    Replacement extends Cell,
    RowPlace extends 0 | 1 | 2,
    ColumnPlace extends 0 | 1 | 2
> = {
    [Row in 0 | 1 | 2]: {
        [Column in 0 | 1 | 2]: [Row, Column] extends [RowPlace, ColumnPlace]
            ? Replacement
            : Board[Row][Column];
    };
};

이동 후 보드는 다음과 같습니다.

 

type AfterFirstMoveMap = ReplaceInBoard<StartingBoard, "X", 0, 1>;
// {
//     0: { 0: " "; 1: "X"; 2: " "; };
//     1: { 0: " "; 1: " "; 2: " "; };
//     2: { 0: " "; 1: " "; 2: " "; };
// }

 

Inferred Types(추론된 타입)

👉 타입스크립트 공식 문서 : 조건부 타입을 통한 타입 추론

시작 보드에서 몇 단계의 이동을 진행한 다음 보드가 어떻게 보일지 타입으로 보여주는 것이 목표입니다.

이것은 타입스크립트에 반복 연산자(for)가 없기 때문에 어렵습니다.

type AllMoves = [["X", 0, 1], ["O", 2, 2]];

type AfterAllMoves = [[" ", "X", " "], [" ", " ", " "], [" ", " ", "O"]];

 

사실 타입스크립트의 타입 시스템은 함수형 언어 혹은 논리적 언어와 비슷합니다.

즉, 생성 후에는 아무 것도 수정할 수 없으며 모든 추론은 이미 정의된 진리를 기반으로 이루어져야 합니다.

 

Thinking Functionally(함수적으로 사고하기)

런타임 코드로 작업했다면 이 함수적 문제를 재귀적으로 다룰 것입니다.
type Move = [Cell, 0 | 1 | 2, 0 | 1 | 2];

// There are two possibilities when calling our applyMoves function:
function applyMoves(board: TicTacToeBoard, moves: Move[]) {
    // 1. If the remaining moves are empty, we can return the board as-is
    if (moves.length === 0) {
        return board;
    }

    // 2. If there's at least one move to apply, we apply it,
    //    then recurse on the new board with the remaining moves
    const nextBoard = replaceInBoard(board, moves[0]);
    const remainingMoves = moves.slice(1);

    return applyMoves(nextBoard, remainingMoves);
}

 

Typing Functionally(함수적으로 타이핑하기)

타입 시스템의 코드는 약간 다르게 보이지만 결과는 같습니다.
type Move = [Cell, 0 | 1 | 2, 0 | 1 | 2];

type ApplyMoves<
    Board extends TicTacToeBoard,
    Moves extends Move[]
> = Moves extends []
    ? Board
    : ApplyMoves<
          ReplaceInBoard<Board, Moves[0][0], Moves[0][1], Moves[0][2]>,
          DropFirst<Moves>
      >;

 

  • 엣지 케이스 : Move가 빈 배열을 extends 하는 경우(따라서 더 이상 이동이 없는 경우) 결과 타입은 보드 자신입니다.
  • 엣지 케이스가 아닌 경우 : ApplyMoves에 대한 재귀 타입 시스템 호출
    • 보드에 Moves 배열의 첫번째 요소를 통한 이동을 적용한 결과(보드)를 다시 보드로 사용
    • 첫 번째 이동을 제외한 모든 Move를 Moves 배열로 사용합니다. 이 Move 배열이 []이면 보드를 리턴합니다.
 

DropFirst 타입은 우리가 타입 추론을 배운 이유입니다.

DropFirst는 배열이어야 하는 T를 받은 다음 나머지 배열(... 또는 spread 연산자)이 첫 번째 목록 구성원을 제외하고도 배열로 추론 가능한지 확인합니다.

가능한 경우 해당 배열이 리턴 타입입니다.

그렇지 않은 경우 빈 배열이 반환됩니다.

type DropFirst<T extends unknown[]> = T extends [any, ...infer U] ? U : [];
 
이제 빈 보드에 이동을 적용해봅시다.
type TwoAppliedMoves = ApplyMoves<StartingBoard, [["X", 0, 1], ["O", 2, 2]]>;
/*
{
    0: { 0: " "; 1: "X"; 2: " "; };
    1: { 0: " "; 1: " "; 2: " "; };
    2: { 0: " "; 1: " "; 2: "O"; };
}
*/
 
이와 같은 직접 재귀 타입은 TypeScript 버전 4.1 이상에서만 사용할 수 있습니다.
(주 : 이전에는 indexed type으로 재귀했음)

타입 시스템 내부의 재귀

탬플릿 리터럴 타입(Template Literal Types)

탬플릿 리터럴 타입을 사용하면 타입 시스템에서 문자열을 기반으로 타입을 추출할 수 있습니다.

이 아래는, 우리가 얻을 최종 결과를 의미하는 타입입니다.

type GameResult = TicTacToe<`
    X 1 1
    O 2 2
    X 2 0
    O 0 2
    X 1 0
    O 0 0
    X 1 2
`>;
 

레벨 1: 기본 알고리즘

타입 시스템의 함수적/논리적 특성을 염두에 두고 이 TicTacToe 타입을 처리하는 방법은 다음과 같습니다.
  1. 위의 문자열을 Moves 배열로 변환합니다.
  2. ApplyMoves를 이용해 해당 Moves 배열의 이동을 보드에 적용합니다.
  3. Winner 타입으로 보드의 최종 상태를 검사하여 승자를 판별합니다.
 
다음은 게임의 승자를 보여주는 TicTacToe 타입입니다.
type TicTacToe<Moves extends string> = Winner<
    ApplyMoves<StartingBoard, ParseRawMoves<Moves>>
>;​
이제 ParseRawMoves 타입을 작성해야 합니다.

레벨 2: 문자열 구문 분석

  1. 각 Step을 \n(엔드라인)에서 배열로 분할합니다.
  2. Move에 해당하는 각 라인 중 사용 가능한 것만 튜플로 변환하여 Moves 배열을 만듭니다.
type ParseRawMoves<Moves extends string> = CollectParsedRawMoves<
    Split<Moves, "\n">,
    [],
    "X"
>;

레벨 3: 문자열 쪼개기

이제 템플릿 리터럴을 이용해 각 라인을 Move로 만듭니다.

type Split<Text extends string, Splitter extends string> = Text extends ""
    ? []
    : Text extends `${infer Prefix}${Splitter}${infer Suffix}`
    ? [Prefix, ...Split<Suffix, Splitter>]
    : [Text];

 

Split 타입은 Text(검사 대상 문자열) 문자열과 Splitter(분할 기준 문자열)  문자열을 사용합니다.
빈 텍스트는 빈 배열을 반환합니다.
아니면 텍스트 내부의 Splitter(분할 기준 문자열) 인스턴스 존재 여부를 확인하여,
추론된 접두사 문자열이 앞에 오고 추론된 접미사 문자열이 뒤에 올 수 있도록 합니다.
 
이것이 타입 시스템 템플릿 리터럴의 특성입니다.
 
문자열 리터럴 타입에 대한 조건 준수를 확인하고 필요에 따라 타입을 추출(추론)할 수 있습니다.
 
추론된 타입 일치가 있는 경우 결과는 먼저 Splitter 문자열(접두사) 앞에 있는 항목이고,
그 다음에는 Splitter(접미사) 뒤에 있는 항목을 분할하기 위한 재귀 호출이 뒤따릅니다.
 
Splitter를 찾을 수 없으면 이 문자열을 더 이상 분할할 수 없습니다. 모든 텍스트를 포함하는 배열을 반환합니다.

레벨 4 : 필터링

이제 endline(\n)에서 문자열을 분할할 수 있으므로 마지막 과제가 하나 남았습니다.
각 문자열 move 줄을  Move 튜플로 변환 후 Moves 배열로 모으는 것입니다.
전체 CollectParsedRawMoves 타입은 약간 지저분하여 여기에 표시된 도우미 타입으로 몇 가지 논리를 분할했습니다.
// 턴을 받아 다음턴으로 전환합니다.
type NextTurn<Turn> = Turn extends "X" ? "O" : "X";
// 입력은 문자열이기 때문에, 문자열을 받아 숫자로 바꿔줍니다.
type IntToString<Int> = Int extends "0"
    ? 0
    : Int extends "1"
    ? 1
    : Int extends "2"
    ? 2
    : never;
// 위의 두 가지 타입을 적용하여 'X 1 2'와 같은 입력 라인을 구문 분석합니다.
type ParseRawMove<Turn, RawRow, RawColumn> = [
    Turn,
    IntToString<RawRow>,
    IntToString<RawColumn>
];
재귀적으로 raw Move 문자열 배열을 멋진 Move 튜플 타입으로 변환하는 타입. 해당 턴에 맞는 것부터 먼저 처리합니다.
// Our CollectParsedRawMoves type takes in:
type CollectParsedRawMoves<
    // 스트링 move 배열을 파라미터로 받는다.
    RawMoves extends string[],
    // 지금까지 move를 모은 배열
    Collected extends Move[],
    // 현재 턴, NextTurn 생성에 사용한다.
    Turn extends "X" | "O"
> =
    // 더 이상 파싱할 string move 배열이 없음. 종료!
    RawMoves extends []
        ? // 지금까지 모은 배열을 리턴하고 종료
          Collected
        : // 사용 가능한 move인지 판별함. (문법에 맞는지? 해당 턴에 이동할 플레이어가 맞는지?)
        RawMoves[0] extends `${infer Pre}${Turn} ${infer RawRow} ${infer RawColumn}${infer Post}`
        ? // 맞으면 재귀적으로 반복
          CollectParsedRawMoves<
              // 이동에 사용한 첫번째 요소를 제외
              DropFirst<RawMoves>,
              // 이동에 사용한 첫번째 요소를 파싱하여 move[]배열 맨 뒤에 추가.
              [...Collected, ParseRawMove<Turn, RawRow, RawColumn>],
              // 다음 턴으로 이동
              NextTurn<Turn>
          >
        : // 못써먹을 타입. 잘못되었음
          CollectParsedRawMoves<
              // 첫번째 요소는 파싱이 종료되었으니 버린다. 나머지 배열은 그대로 사용하지만,
              DropFirst<RawMoves>,
              // 첫번째 요소는 버리고 원래 모았던 Move[] 대상으로 계속한다.
              Collected,
              // 턴을 반복한다.
              Turn
          >;

 

The Final Product(최종 코드)

최종 코드 실행해보기

 

TS Playground - An online editor for exploring TypeScript and JavaScript

The Playground lets you write TypeScript or JavaScript online in a safe and sharable way.

www.typescriptlang.org

다음에 해볼 것들

Type Challenges : 타입 챌린지 도전하며 타입 시스템 문제 풀어보기

DefinitelyTyped : 지금까지 배운 타입스크립트 지식으로 오픈소스 기여하기

반응형