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'
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";
- Opponent 타입이 Matchups[Player]의 키 중 하나면 해당 값 (Matchups[Player][Opponent])을 리턴합니다.
- 아니면 비깁니다. ("Draw")
type Result = RockPaperScissors<"Paper", "Scissors">; // 'Loss'
튜플 타입
튜플 타입을 이용해 게임 보드를 선언합니다.
튜플 타입은 고정길이 배열의 정해진 위치에 정해진 타입이 오는 타입입니다.
type Cell = " " | "X" | "O";
type TicTacToeBoard = [
[Cell, Cell, Cell],
[Cell, Cell, Cell],
[Cell, Cell, Cell]
];
- cell은 빈 문자열(아직 배치되지 않음), 'X' 또는 'O'와 같이 게임 보드에 있는 모든 슬롯의 내용입니다.
- TicTacToeBoard는 튜플의 튜플이며 3x3 보드의 그리드를 나타냅니다.
승리 조건
type Victory<Player, Board> = Board extends WinningBoard<Player> ? true : false;
type WinningBoard<Player> =
| DiagonalVictory<Player>
| HorizontalVictory<Player>
| VerticalVictory<Player>;
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
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(매핑된 타입)은 keyof 연산자와 같은 키 목록을 가져와 새로운 타입 멤버를 만듭니다.
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 },
}
보드에 조각을 배치하기
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: 기본 알고리즘
- 위의 문자열을 Moves 배열로 변환합니다.
- ApplyMoves를 이용해 해당 Moves 배열의 이동을 보드에 적용합니다.
- Winner 타입으로 보드의 최종 상태를 검사하여 승자를 판별합니다.
type TicTacToe<Moves extends string> = Winner<
ApplyMoves<StartingBoard, ParseRawMoves<Moves>>
>;
레벨 2: 문자열 구문 분석
- 각 Step을 \n(엔드라인)에서 배열로 분할합니다.
- 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];
레벨 4 : 필터링
// 턴을 받아 다음턴으로 전환합니다.
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>
];
// 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 : 지금까지 배운 타입스크립트 지식으로 오픈소스 기여하기
'FrontEnd' 카테고리의 다른 글
리액트 패턴 : 타입스크립트를 활용해 as Props 사용하기 (0) | 2022.04.28 |
---|---|
리액트로 XState 시작하기 (0) | 2022.04.28 |
타입스트립트의 타입시스템으로 산수 구현하기 (0) | 2022.04.27 |
React Remix Framework로 알아보는 중첩 경로(nested routing) (0) | 2022.04.26 |
타입스크립트 함수의 시그니처와 리스코프 치환 원칙 (0) | 2022.04.09 |