영상 버전 TSConf 2020
템플릿 문자열 리터럴을 이용해 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(최종 코드)
다음에 해볼 것들
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 |