−검색 화면에 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 지원을 설명했다. 유니온의 등장과 판별 속성에 따른 제어 흐름 분석은 같은 시점의 변화가 아니다.[각주 3][각주 4]
여기서 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. 실행 시 타입은 제거되므로 외부 입력 검사는 실행 코드로 남는다.
+개요
대수적 데이터 타입(algebraic data type)은 여러 값을 함께 갖는 구조와, 여러 대안 중 하나를 고르는 구조를 조합해 데이터를 표현하는 방식이다. 대수적이라는 이름은 값의 조합을 곱과 합으로 해석할 수 있다는 데서 나온다.
회원의 이름과 이메일을 함께 갖는 레코드는 두 필드의 조합이다. 반면 검색 결과가 로딩·성공·실패 중 하나라면 경우마다 필요한 데이터가 달라서, 성공에는 결과 목록이, 실패에는 오류가 필요하다. 객체의 모든 필드를 선택 사항으로 두는 대신 각 대안을 따로 정의하면 무엇이 함께 존재해야 하는지 타입에 나타낼 수 있다.
이런 모델링에서는 프로그램이 만들 수 있는 조합 가운데 업무에서 의미 있는 조합을 골라 타입으로 표현한다. 소비하는 코드는 경우마다 그에 맞는 필드를 다룬다. “잘못된 상태를 표현하기 어렵게 만든다”는 흔한 설명도 여기서 나온다.
타입은 데이터의 모양과 관계를 설명하지만, 어떤 주문이 실제로 결제됐는지나 응답을 보낸 서버를 신뢰할 수 있는지 같은 사실은 실행 중에 확인해야 한다. 상태의 표현, 전이 규칙, 외부 입력 검증을 각각 어디에서 맡는지 구분하면 타입 모델이 맡는 범위가 분명해진다.
연혁
1969년: Burstall의 구조적 귀납 연구가 나왔다.[각주 1]
1970년대: Robin Milner의 ML이 에든버러의 정리 증명기 LCF를 위한 메타 언어로 출발했다.[각주 1]
1977년: Burstall이 NPL을 내놓았고, Darlington과 함께 프로그램 변환 연구를 발표했다.[각주 1]
1980년: Burstall·MacQueen·Sannella가 LISP Conference에서 「HOPE: An Experimental Applicative Language」를 발표하며 Hope를 소개했다.[각주 2]
1987년 9월: 포틀랜드 FPCA에서 비엄격 순수 함수형 언어가 여럿 있는 상황을 두고 공통 언어를 만들자는 논의가 있었고, 이 논의가 Haskell로 이어졌다.[각주 1]
1990년 4월 1일: Haskell의 첫 보고서가 나왔다. 대수적 데이터 선언은 함수·타입 추론·패턴 매칭과 함께 쓰는 구성 요소가 됐다.[각주 1]
2010년 7월: Haskell 2010 보고서 최종판이 나왔다. 생성자와 필드로 데이터 타입을 선언하는 방식은 이 판의 공식 정의에도 그대로 들어 있다.[각주 3][각주 4]
2015년 1월 16일: Microsoft가 TypeScript 1.4를 발표했고, 이 판에서 유니온 타입이 도입됐다.[각주 5]
2016년 9월: TypeScript 2.0이 판별 속성으로 태그가 붙은 유니온을 좁히는 기능을 지원했다.[각주 6][각주 7]
타입 추론과 데이터 선언의 계보
타입 추론과 데이터 선언은 함수형 언어에서 함께 쓰이게 됐지만 출발한 계보가 다르다. ML은 다형적 타입 추론의 발전에 큰 영향을 주었는데, Haskell 설계자들의 2007년 회고는 초기 ML에 대수적 타입이 없었다고 적는다. 같은 회고는 언어 기능으로서의 대수적 타입을 설명하면서 Burstall의 NPL과 Burstall·MacQueen·Sannella의 Hope를 선행 사례로 들기 때문에, ML의 기원과 ADT 선언의 계보는 구분해서 봐야 한다.[각주 1]
회고는 초기 ML과 그 뒤의 Standard ML도 구별하고, Miranda 역시 대수적 타입을 가진 선행 언어로 기록한다. 이름이 비슷한 언어의 여러 판을 하나로 묶으면 이 변화가 보이지 않는다.[각주 1]
생성자와 경우 분석
1977년의 NPL과 1980년의 Hope로 이어지는 흐름에서는 데이터가 어떤 경우들로 만들어지는지 언어 안에 선언하고, 각 경우를 패턴으로 분해하는 방식이 중요해졌다. Haskell 회고는 Burstall의 구조적 귀납 연구와 Darlington과 함께한 프로그램 변환 연구를 이 프로그래밍 방식의 배경으로 든다.[각주 1]
Hope 논문은 서로 구별되는 생성자로 사용자 정의 타입의 값 모양을 정하고 각 경우를 다루려 한 동기를 보여준다. 트리라면 비어 있거나, 값을 가진 끝점이거나, 두 하위 트리를 가진 가지일 수 있다. 선언과 경우 분석이 짝을 이루면 소비하는 코드는 데이터가 가질 수 있는 모양을 그대로 따라갈 수 있다.[각주 2]
선언을 재귀적으로 쓰면 리스트나 구문 트리를 만들 수 있고, 값을 쓰는 쪽에서는 패턴 매칭으로 형태에 맞는 계산을 고른다.[각주 3] 성공 값과 실패 값을 같은 결과 타입에 담을 때도 생성자마다 필요한 데이터를 붙이므로, “성공이면 결과가 있다”는 관계가 프로그램 곳곳의 암묵적인 약속으로 흩어지지 않고 데이터 선언에 드러난다.
합과 곱
합과 곱은 이렇게 구성한 데이터의 관계를 가리킨다. 태그로 구별되는 대안끼리는 합이고, 한 대안 안에 함께 들어 있는 필드끼리는 곱이다. 2개 값과 3개 값을 갖는 두 필드를 함께 두면(곱) 2×3=6개 조합이 나오고, 태그로 나눠 한 대안은 2개 값 중 하나를, 다른 대안은 3개 값 중 하나를 갖게 하면(합) 2+3=5개 경우가 된다.
Haskell 회고에 따르면 Haskell은 대수적 타입을 sum of products로 선언하는 Miranda의 방식을 받아들였다. Hope와 Standard ML처럼 합 구조와 튜플 곱을 나누어 표현하는 방식과 비교하면, 트리의 가지가 두 필드를 직접 받는지 두 하위 트리를 묶은 튜플 하나를 받는지에서 선언 형태가 달라진다.[각주 1]
가능한 값들의 관계와 그 관계를 선언하는 문법은 구분해서 볼 수 있다. Haskell 문법은 그 관계를 적는 한 방식이고, 표기가 달라도 같은 데이터 구성을 표현할 수 있다.
유니온과 판별 속성으로 옮겨온 경우 분석
TypeScript의 유니온 타입과 판별 속성은 함수형 언어의 생성자 문법과 모양이 다르지만, 데이터 대안을 선언하고 대안별 필드를 다루는 문제를 객체 타입 안에서 풀어낸다. 상태 모델링에서는 로딩·성공·실패를 독립 필드의 우연한 조합으로 두지 않고 상태마다 필요한 데이터를 함께 선언할 수 있다. 이전 결과를 유지한 채 다시 로딩하는 화면처럼 다른 조합이 필요하면 그 조합도 모델에 명시한다.
타입 표기는 어떤 값을 표현할지 정한다. 어떤 사건 뒤에 다음 상태로 갈 수 있는지는 함수와 모듈 경계의 전이 규칙으로 표현한다.
현대의 타입 모델이 맡는 경계
합 타입은 현재 데이터의 모양을 나타내고, 매개변수적 다형성은 그 구조를 여러 타입에 재사용하게 한다. Result<T, E>에서 성공·실패의 대안과 T·E의 재사용을 나누어 보면 두 개념의 역할이 보인다.
TypeScript에서는 타입 표기가 실행 시 제거되므로 외부 JSON의 필드는 실행 코드로 검사해야 한다. 타입 단언은 검증 코드를 만들지 않는다. 내부의 경우 분석에서는 never를 이용해 처리하지 않은 대안이 남는지 검사할 수 있다.[각주 8][각주 9]
판별 유니온 상태 예제
예제 코드는 이 경우 분석을 TypeScript의 검색 상태로 옮긴 것이다. 성공에는 결과가, 실패에는 오류가 있다는 관계만 표현하며 실제 검색 API는 포함하지 않는다.
type Search =
| { kind: 'loading' }
| { kind: 'ready'; data: string[] }
| { kind: 'failed'; error: string };
function describe(state: Search): string {
switch (state.kind) {
case 'loading': return 'Loading';
case 'ready': return state.data.join(', ');
case 'failed': return state.error;
default: {
const impossible: never = state;
return impossible;
}
}
}
console.log(describe({ kind: 'ready', data: ['Ada', 'Grace'] }));
출력은 Ada, Grace다. { kind: 'ready' }는 필수 결과(data)가 빠진 값이라 컴파일 오류가 난다. default 분기의 never 대입은 새 상태를 추가하고도 case를 빠뜨렸을 때 오류가 나도록 한다.