대수적 데이터 타입과 상태 모델링
검색 화면에 loading, data, error를 각각 두면 로딩 중인데 이전 오류가 남아 있거나, 성공한 결과와 실패 표시가 함께 켜지는 조합을 만들 수 있다. 각 필드의 타입은 맞아도 화면 전체의 뜻은 어긋나는 셈이다. 화면이 실제로 가질 수 있는 경우부터 정한 뒤, 각 경우에 필요한 필드를 묶으면 이런 모순을 줄일 수 있다.
이 방법은 대수적 데이터 타입(algebraic data type)의 합과 곱으로 설명할 수 있다. 한 경우가 여러 필드를 함께 가지는 것은 곱이고, 여러 경우 중 하나를 고르는 것은 합이다. 예를 들어 성공은 결과 배열을, 실패는 오류 문장을 가지며, 화면은 둘 중 한 경우에 속한다.1
데이터의 모양을 여러 대안으로 나타내기까지
2007년 Haskell 설계자들의 회고 논문은 Haskell의 데이터 선언을 설명하면서 NPL·Hope·Standard ML·Miranda 등의 선행 언어를 함께 다룬다. 이 기록에서는 대안을 생성자로 나누고 각 생성자에 필드를 붙이는 표현이 함수형 언어들 사이에서 발전한 과정을 확인할 수 있다. 특정 현대 언어 하나를 이 개념 전체의 발명자로 정하는 것보다, 각 언어가 어떤 표현과 검사 기능을 제공했는지 구별하는 편이 정확하다.2
TypeScript는 2015년 1.4에서 A | B 형태의 유니온 타입을 도입했고, 2016년 2.0에서는 문자열 리터럴 꼬리표를 검사해 해당 대안으로 타입을 좁히는 tagged union 지원을 설명했다. 유니온의 등장과 판별 속성에 따른 제어 흐름 분석은 같은 시점의 변화가 아니다.34
여기서 algebraic data type과 abstract data type은 구별해야 한다. 전자는 대안과 필드의 조합을 나타내는 구조를, 후자는 표현을 감추고 연산을 통해 사용하는 추상화를 가리킨다. 두 용어가 ADT라는 약자를 공유해서 문맥을 확인할 필요가 있다.2
경우를 정하면 필드도 함께 정해진다
다음은 문자열 목록을 검색하는 화면의 작은 모델이다. kind는 어느 경우인지를 나타내는 판별 속성이다. 코드가 ready를 확인하면 TypeScript는 그 분기에서 data가 있는 것으로 다룬다.
type SearchState =
| { kind: 'idle' }
| { kind: 'loading'; query: string }
| { kind: 'ready'; data: string[] }
| { kind: 'failed'; message: string };
function describe(state: SearchState): string {
switch (state.kind) {
case 'idle': return 'idle';
case 'loading': return `loading:${state.query}`;
case 'ready': return `items:${state.data.length}`;
case 'failed': return `error:${state.message}`;
}
const missing: never = state;
return missing;
}
console.log(describe({ kind: 'ready', data: ['Ada', 'Grace'] }));
console.log(describe({ kind: 'failed', message: 'offline' }));
출력은 items:2와 error:offline이다. 실패에는 message가 있고 성공에는 data가 있다는 관계를 타입에 담았으므로, { kind: 'ready' }처럼 결과가 빠진 객체는 타입 검사에서 거절된다. 마지막 never 대입은 새 경우를 추가하고 switch 처리를 빠뜨렸을 때 그 누락을 드러낸다.5
외부 JSON은 어디에서 검사할까
외부 응답을 unknown으로 받아 검사를 시작한다. as SearchState라고 적으면 컴파일러에게 그 타입으로 간주하라고 지시할 뿐, JSON의 실제 필드를 검사하지 않는다.6 다음 함수는 JSON.parse로 얻은 데이터에서 이 예제의 성공 응답 모양을 검사한다. getter나 Proxy가 코드를 실행하는 임의의 JavaScript 객체는 이 입력 계약 밖이다.
type Ready = { kind: 'ready'; data: string[] };
type ParseResult =
| { ok: true; value: Ready }
| { ok: false; error: 'INVALID_READY' };
function parseReady(input: unknown): ParseResult {
if (typeof input !== 'object' || input === null ||
!('kind' in input) || !('data' in input)) {
return { ok: false, error: 'INVALID_READY' };
}
const kind = input.kind;
const data = input.data;
if (kind !== 'ready' || !Array.isArray(data)) {
return { ok: false, error: 'INVALID_READY' };
}
const snapshot = [...data];
if (!snapshot.every(item => typeof item === 'string')) {
return { ok: false, error: 'INVALID_READY' };
}
return { ok: true, value: { kind: 'ready', data: snapshot } };
}
console.log(parseReady({ kind: 'ready', data: ['Ada'] }).ok);
console.log(parseReady({ kind: 'ready' }).ok);
console.log(parseReady(null).ok);
출력은 true, false, false다. 배열을 복사한 뒤 그 복사본을 검사하므로 원래 응답 객체의 배열을 나중에 바꾸더라도 반환한 배열에는 그 변경이 반영되지 않는다. 객체의 필드 조합을 검사하는 이 경계와, 내부 코드에서 대안을 빠짐없이 처리하는 타입 검사는 역할이 다르다.
타입으로 줄일 오류의 범위를 먼저 정해야 한다. 위 모델은 성공에 결과 배열이 필요하다는 조건을 담지만, 검색 결과가 최신 요청의 것인지나 접근 권한이 있는지는 판단하지 않는다. 빈 문자열을 금지하려면 값 검증을 추가하고, 허용된 상태 전이만 제공하려면 전이 함수의 입력과 출력을 설계한다. 상태 패턴이 상태에 따른 행동을 나누는 방법이라면, 여기서는 먼저 어떤 데이터 조합을 표현할지 고른다.
예제 환경: strict 검사를 켠 TypeScript. 실행 시 타입은 제거되므로 외부 입력 검사는 실행 코드로 남는다.
각주
- Haskell 2010 Language Report, §4.2.1 “Algebraic Datatype Declarations”. 공식 보고서. ↩
- Paul Hudak, John Hughes, Simon Peyton Jones, Philip Wadler, A History of Haskell: Being Lazy With Class, HOPL III, 2007, §5 도입부, §5.1 및 §5.3. 저자 제공 논문. ↩
- Microsoft, “Announcing TypeScript 1.4”, 2015-01-16, “Union Types”. 당시 발표. ↩
- Microsoft, “TypeScript 2.0 is now available!”, 2016-09-22, 및 2.0 릴리스 노트의 “Tagged union types”. 발표, 릴리스 노트. ↩
- TypeScript Handbook, “Exhaustiveness checking”. 공식 문서. ↩
- TypeScript Handbook, “Type Assertions”. 공식 문서. ↩