jq 프로그램에서 타입을 추론해 더 나은, 이해 가능한 오류 메시지를 만들기까지의 여러 해에 걸친 여정 기록.
프로그래머는 묘한 사람들이다. 그들은, 아니 사실 _우리_는, 프로그래밍 언어라든가 타입 같은 비인격적인 개념에 이상한 애정을 갖게 된다. 나도 그 애정에서 자유롭지 않다. 나는 타입을 사랑한다. 구체적인 도메인 모델을 구성하고, 타입들 사이의 관계를 세우고, 도메인 모델을 다듬어 가며 두려움 없이 코드를 리팩터링할 수 있는 능력을 사랑한다.
하지만 프로그래밍의 많은 구석에서 타입은 프로그래머를 끌어올리는 지렛대가 아니라 발목을 잡는 족쇄처럼 작동한다. 소프트웨어를 만들면서 프로그래머는 종종 밑바닥의 도메인을 _탐색_해야 하고, 변하는 도메인에 맞추기 위해 프로그램을 다시 빚어야 하며, 변화가 있을 때마다 타입을 업데이트해야 한다.
문서화되지 않은 API를 실제로 써 보며 발견한다든지, jq나 xpath로 특수한 JSON/XML 파일을 처리한다든지, 서비스나 프로세스를 접착제로 이어 붙이는 스크립트를 만든다든지, 여러 도메인 선택지를 빠르게 프로토타이핑하기 위해 그 가능성들을 모두 포괄하는 초도메인을 구성한다든지… 이런 상황에서는 타입에서 출발할 수 없다. 타입에 손을 뻗어 도달해야 한다. 그래서 내가 원래는 선호하는 정적 타입의 단단하고 경직된 도메인보다, 느슨한 도메인 모델을 갖는 동적 타이핑이 훨씬 나은 대안이 된다.
그런데 동적 타입 환경에서 프로그래밍하면서 한 가지가 꽤 puzzling했다. 모든 프로그램에는 타입이 있다. 딕셔너리만으로 이루어진 Python 코드를 작성할 때, 우리는 아무 제약도 없는 환경에서 코딩하는 게 아니다. 타입은 존재한다. 우리는 그것에 주석을 달지 않을 뿐이고, 그것을 활용하지 않을 뿐이다. 언어의 타입 시스템을 통해 타입을 구체적으로 정의하고 그 이점을 얻는 데 시간을 쓰는 대신, 노력도 이득도 둘 다 포기한다. Python이나 JavaScript에서 “type error”를 맞닥뜨릴 때마다, 나는 정적으로 그걸 막을 수 있는 방법이 있지 않을까 계속 궁금해했다.
이 감정은 연구 프로젝트에서 jq를 쓰기 시작하면서 더 강해졌다. jq는 버리는 스크립트를 쓰고, 데이터 통계를 계산하고, 필터링하는 데 꽤 괜찮은 도구다. 나는 실험할 때마다 돌리는 스크립트를 몇 개 갖고 있었고, 무엇이 어떻게 실패했는지 등을 보곤 했다. 그런데 뭔가 잘못하면 jq가 주는 에러 메시지가 끔찍했다. jq는 디버그 출력 기능을 제공하긴 하지만, 그게 언어와 상호작용하는 보통의 모드는 아니다.
jq와의 보통 상호작용 방식은 프로그램을 실행하고, 결과를 보고, 틀렸음을 확인한 다음 왜 틀렸는지 알아내는 것이다. 내 해결책은 이상 현상을 찾을 때까지 프로그램 끝에서부터 파이프를 계속 지우는 것이었다. 예를 들어 이런 프로그램이 있다면(이건 내가 실제로 겪은 이슈다):
def count_bins($averages):
$averages
| map(.bin)
| group_by(.)
| map({
bin: .[0],
count: length
});
나는 먼저 맨 끝의 map을 지우고, 그다음 group_by를 지우고, 그다음 map을 지우고… 이런 식으로 갔다.
에러 메시지의 문제가 무엇인지 보기 위해 더 단순한 예를 보자:
$ "alperen" | explode | "a" + .[]
> jq: error (at <unknown>): string ("a") and number (97) cannot be added
다음 애니메이션은 실행을 자세히 보여 준다:
$"alperen"|explode|"a"+.[]
literal
"alperen"
explode
[97,108,112,101,114,101,110]
.[]produces a stream — each value flows through"a"+independently
97|"a"+97 string + number!
108|"a"+108 unreached
112|"a"+112 unreached
101|"a"+101 unreached
114|"a"+114 unreached
101|"a"+101 unreached
110|"a"+110 unreached
runtime error
jq: error: string ("a") and number (97) cannot be added
이 프로그램에서 일어나는 일은 explode가 문자열의 각 문자에 대응하는 유니코드 코드포인트 숫자 배열을 반환한다는 것이다. 그 다음 이 배열은 배열 이터레이터 .[]를 통해 + 연산자로 분배된다. +는 다음에 대해서는 정의되어 있지만
<string>
+ <string>
그리고 <number> + <number>에 대해서도 정의되어 있지만,
<string>
+ <number>
에 대해서는 정의되어 있지 않다. 그래서 동적 타입 에러가 난다.
하지만 97이 어디서 왔는지 이해하는 것은 explode의 출력값을 따로 확인하지 않고서는 거의 불가능하다. 에러 메시지가 에러를 던진 연산자에 대한 국소 정보만 담고 있고, 값의 provenance(기원/유래)가 추적되지 않기 때문이다. (관심 있는 분을 위해 덧붙이면, 그 이유는 깊은 웜캔으로 이어지고 결국 provenance 추적의 오버헤드가 잠재적 이득에 비해 너무 크다는 결론으로 귀결된다. 관련 이슈 몇 가지: #715, #1891, #1657, #1974, #1216.)
이 타입 에러를 정적으로 판정할 수 있을까? 나는 가능하다고 생각했다.
예를 들어 jq는 explode의 입력이 문자열이어야 한다고 알려 준다:
$ jq -nc 'null | explode'
jq: error (at <unknown>): explode input must be a string
또 그 출력은(적어도 어떤 경우에는) 정수 배열임을 알 수 있다:
$ jq -nc '"" | explode'
[]
$ jq -nc '"abc" | explode'
[97, 98, 99]
따라서 이론적으로 explode: string -> array<int>를 추론할 수 있다.
파이프는 단순한 합성이므로,
"alperen" | explode: any ->
array<int>
가 된다. 왜냐하면 "alperen": any -> string으로도 타이핑할 수 있고, e1 | e2를 e1: a -> b, e2: b -> c ==> e1 | e2: a -> c로 볼 수 있기 때문이다.
그렇다면 +는 어떨까? jq 문서를 보거나 상당한 시간 써 보며 익혀야 하지만, 대략 다음과 같다:
| e1 | e2 | e1 + e2 |
|---|---|---|
| number | number | number |
| string | string | string |
| array | array | array |
| object | object | object |
| null | any | e2 |
| any | null | e1 |
.[]는 좀 더 복잡하다. .[]는 배열/객체 이터레이터이며 요소들의 stream을 만든다. 단순화를 위해 다음과 같다고 하자:
array<T> ->
T
그러면 "a" + .[]는 string + T가 되고, 이는
T =
string
일 때만 정의된다. 하지만 explode가 array<int>를 만든다는 것을 알고 있으므로, 이 프로그램은 타입 에러를 던질 것임을 추론할 수 있다. 왜냐하면
string +
int
는 정의되어 있지 않기 때문이다.
즉, 이런 타입들을 발견하는 알고리즘적 절차만 있다면 어떤 입력을 주든 프로그램을 실행하기 전에 이 에러를 막을 수 있고, 에러 메시지도 훨씬 더 유익해진다. 97 같은 국소 정보에 제한되는 대신, 그 출처인 "alperen" | explode까지 말해 줄 수 있기 때문이다.
이 아이디어 자체는 새롭거나 참신한 것은 아니다. 많은 사람들이 동적 타입 언어를 위한 정적 타입 검사 절차를 개발하는 데 수천 시간을 바쳤다. Python의 mypy, pyrefly, ty, TypeScript가 결국 이겨 버린 수많은 JavaScript 방언들. 이런 것들은 점진적 타입 시스템(gradual type systems)으로 알려져 있는데, 프로그램의 일부는 정적으로 알 수 없는 타입을 가질 수 있고 정적으로 알려진 부분과 함께 동작한다. 나는 그걸 jq에 넣으려는 여정을 기록하고, 그 과정에서 멋진 기법 몇 가지를 보여 주려 한다.
내가 처음 떠올린 아이디어는 입력에 대한 접근(Accesses)을 기반으로 입력에 대한 제약을 추적하는 것이었다. 예를 들어 다음 프로그램을 보자:
.[] | .age, .name
이 프로그램은 입력 배열을 순회하며 각 원소의 age와 name 필드를 각각 접근해 반환한다. 다음 입력에 대해:
[{"age": 23, "name": "Simon"}, {"age": 24, "name": "Peyton"}]
프로그램은 다음 출력을 만든다:
23
"Simon"
24
"Peyton"
연산자들이 동작하는 방식을 역공학하면, 입력은 name과 age 필드를 가진 objects의 array여야 한다고 추론할 수 있다. 이를 다음처럼 표현할 수 있다:
[ { name: <>, age: <> } ]
마름모(<>)는 제약되지 않은 타입을 뜻한다. 이 표현을 얻으면, 이 프로그램의 어떤 입력에 대해서도 타입 체크에 활용할 수 있다.
[{"age": 23, "name": "Simon"}, "Jones"]가 주어지면, 배열의 두 번째 원소가 객체가 아니라는 것을 정확히 추론할 수 있고, 이는 프로그램에서의 타입 에러다. 이 접근법의 한 문제는 jq가 매우 관대하다는 것이다. 예를 들어,
[{"age": 23, "name": "Simon"},
{"aqe": 24, "name": "Peyton"}]
은 완전히 유효한 입력이다. (두 번째 레코드에서 age 필드가 오타 난 것에 주목하라.) 객체에 필드가 존재하지 않으면 jq는 그 필드에 대해 null을 반환한다. 그래도 이 방식으로 할 수 있는 일은 많다.
원래 설계에서 나는 각 연산자에 대해 제약을 만들어 내는 규칙을 제공하고, 병렬로 사용될 때 서로 다른 제약들을 병합했다.
C :: .[] --> C : [<>]
C :: [n] --> C : [<>]
C :: .field --> C : { field: <> }
C :: F1, F2 --> (C :: F1 --> C1, C :: F2 --> C2) C : C1 & C2
요약하면, _배열 접근_이나 _이터레이션_은 타입이 배열임을 뜻하고, _필드 접근_은 해당 필드를 가진 객체임을 뜻하며, _병렬 연산_은 타입을 서로 병합한다. 타입을 병합할 수 없으면 타입 에러다. 이 제약 스타일은 파이프(|)를 의미 있게 표현할 수 없어서, 파이프에 대해서는 매우 특수한 구현이 필요했다.
나는 몇 시간 만에 이걸 구현했고, 그날 저녁에 멋진 데모까지 만들었다!
간단한 분석으로, jq 실행에서 받는 맥락 없는 에러 메시지를 맥락 있는 에러 메시지로 바꿀 수 있었다. 아래 예를 보라:
$ jq -nc '[{"name": "John", "age": 25}, {"name": "Jane", "age": 30}] | .[] | .age, .name | {v: .a}'
> jq: error (at <unknown>): Cannot index number with string "a"
이게 이렇게 바뀐다:
$ tjq --input '[{"name": "John", "age": 25}, {"name": "Jane", "age": 30}]' --filter '.[] | .age, .name | {v: .a}'
Shape mismatch detected!
at [0].age
Expected: {a: <>}
Got: 25
다음 애니메이션은 실행과 shape 추론이 어떻게 동작하는지 보여 준다:
$.[]|.age,.name|{v:.a}
input
[{name:"John",age:25}, {name:"Jane",age:30}]
.[]
{name:"John",age:25}
{name:"Jane",age:30}unreached
.age,.name
25 via .age
"John"via .name unreached
{v:.a}
25|{v:25.a}can't index number!
jq error
Cannot index number with string "a"
나는 엄청 신났고, Hacker News 첫 페이지도 들썩였다. 하지만 이항 연산자, 아주 단순한 + 연산자를 구현하려고 하자 막혔다. 문제의 뿌리는 파이프(|)가 잘 안 됐던 것과 같은 이유였다. 접근을 기반으로 제약을 만들 수는 있었지만, 프로그램을 통해 값이 어떻게 흐르는지 추적할 실제 방법이 없었다. 다음 프로그램을 보자:
3 + .a
이전 절차는 C :: { a: <> }까지만 말할 수 있었다. .a가 숫자여야 한다는 걸 말할 수는 없었다. 값이 사용되는 맥락에 따라 제약을 추가할 방법이 없었기 때문이다. .a는 결과 타입에 대한 정보를 전혀 만들어 내지 못했다. 함수의 입력 타입은 감지할 수 있지만 출력 타입은 감지하지 못하는 것처럼 느껴졌다. 그래서 두 필터를 합성할 때 정보를 전달하지 못했다.
나는 3개월 내내 막혀 있었다. 제약된 필터와 출력 타입을 함께 반환하려고도 해 봤지만, 출력 타입을 입력 타입과 연결해야 했고 그걸 할 방법이 없었다. jq의 스트림 의미론은 구현을 쉽게 해 주지 않았다. 동시에 여러 입력과 여러 출력을 가질 수 있기 때문이다. jq에서 .[]를 실행하면 입력들의 스트림을 생성하고, 각 입력은 자기 방식으로 개별 실행된다.
따라서 [“alp”, 3, null] | .[]를 실행하면 jq는 먼저 3개 원소의 배열을 만들고, 그걸 3개의 스트림으로 분해해, 뒤따르는 어떤 필터로도 모두 전달한다. 즉 이 필터의 출력에 3개의 타입을 부여하고 계속 전달해야 한다. 타입 추론은 expr: T -> T가 아니라, 결과를 전부 들고 다녀야 하는 expr: T -> [T]에 가깝다. 전체 알고리즘이 훨씬 더 지저분해진다 :/.
마침내 무엇을 해야 하는지 알아냈다. 분석 전반에 걸쳐 제약을 누적하는 타입 변수를 추가했다. 이 제약들은 여러 레벨에서의 상호작용을 포착할 수 있게 해 준다. 이전 예제 3 + .a를 보자.
T1 + T2는 매우 느슨한 연산이다. Null + <>,
<> +
Null
, Number + Number, String + String, Array + Array, Object + Object를 허용하며, 동형 쌍 중에서
Bool +
Bool
만 금지된다. 이 연산을 타이핑할 때 먼저 내부 타입 T1과 T2를 계산한다. 내부 타입 계산 결과에 따라 결과 타입을 결정하고, 내부 연산이 만든 타입 변수에 제약도 걸 수 있다.
다음 코드 조각은 이런 결과로 이어진다:
let l = Shape::build_shape(l, shapes.clone(), ctx, filters);
let r = Shape::build_shape(r, shapes, ctx, filters);
match (l, r) {
(Shape::TVar(t0), Shape::TVar(t1)) => {
if t0 == t1 {
Shape::tvar(t0)
} else {
let current_shape = ctx.get(&t0).unwrap().clone();
let current_shape = Shape::merge_shapes(
current_shape,
Shape::tvar(t1),
ctx,
);
ctx.insert(t0, current_shape);
Shape::tvar(t0)
}
}
// null + X
// X + null
(Shape::Null, s) | (s, Shape::Null) => s,
// num + num
(Shape::Number(n1), Shape::Number(n2)) => match (n1, n2) {
(None, None) | (Some(_), None) | (None, Some(_)) => {
Shape::Number(None)
}
(Some(n1), Some(n2)) => Shape::Number(Some(n1 + n2)),
},
// num + (num | null), (num | null) + num
(Shape::Number(_), Shape::TVar(t))
| (Shape::TVar(t), Shape::Number(_)) => {
let current_shape = ctx.get(&t).unwrap().clone();
let current_shape = Shape::merge_shapes(
current_shape.clone(),
Shape::Union(
Box::new(Shape::Number(None)),
Box::new(Shape::Null),
),
ctx,
);
ctx.insert(t, current_shape);
Shape::Number(None)
}
// str + str
(Shape::String(s1), Shape::String(s2)) => match (s1, s2) {
(None, None) | (Some(_), None) | (None, Some(_)) => {
Shape::String(None)
}
(Some(s1), Some(s2)) => {
Shape::String(Some(format!("{s1}{s2}")))
}
},
// other cases such as array + array, object + object
}
다음 애니메이션은 타입이 프로그램을 통해 어떻게 흐르는지를 보여 주며 메커니즘 자체를 설명한다:
$3+.a
type 3
Number(3)
type .a
TVar(T)— input constrained to{a:T}
match +
Number(3) + TVar(T)
arm fires
(Number,TVar(t))— 4th arm selected
constrain T
T:number|null
result
input:{a:number|null}
output:number
예컨대 3 + .a의 경우, 내부 연산 .a는 현재 컨텍스트 변수를 키 "a"를 가진 객체 {"a": T}로 제약하면서 타입 변수 T를 만들어 낸다. 그리고 코드 예시의 4번째 match arm에 따라 T: number | null을 계산한다.
이 결과를 얻은 직후 또 다른 장애물에 부딪혔다. 이 분석은 많은 정보를 잃는다. 예를 들어 . + .에 부여되는 타입은 Any -> Any가 된다. 이 타입은 이미 틀렸다. jq는
Bool +
Bool
을 받아들이지 않는데 이를 배제하지 못하기 때문이다. 특히 타입 빼기(type subtraction) 연산이 없어서 그렇다. 더 나아가 Any/Bool -> Any/Bool도 맞지만 부정확하다. 나중에 null | . + .처럼 구체 입력을 기반으로 출력 타입을 계산할 때, 결과 타입은
null
| number | string | array | object
가 되지만, 실제로는 결과 타입이 null이어야 한다는 것을 우리는 알고 있다.
. + .에 타입을 부여하는 과정은 다음과 같다:
$.+.
type .(left)
TVar(T)— identity, passes through input type variable
type .(right)
TVar(T)— same input, same variable
match +
TVar(T) + TVar(T)
arm fires
(TVar(t0),TVar(t1))— 1st arm
check t0 == t1
T == T— same variable, return TVar(T)
constrain T
T— unconstrained, no new information
imprecise but correct
input:T— unconstrained
output:TVar(T)— normalizes to Blob(any type)
number+number를 통과시켜도 T에 대해 아무것도 배우지 못하므로, 결과는 어떤 값이든 될 수 있다.
그래서 나는 내 한계가 무엇인지 알아보고, 내가 풀고 싶은 문제를 해결하는 최선의 방법을 이해하기 위해 문헌의 정식화로 들어가기로 했다. 나는 심연을 들여다보았고(농담이다. 대신 Giuseppe Castagna의 set-based types 관련 글을 약 100시간쯤 읽었다. 그는 Programming with Union, Intersection and Negation Types를 썼고, 이는 그의 박사과정 학생 Guillaume Duboc과 함께 Elixir를 위한 타입 시스템 개발로 이어졌다) 돌아와 보니 설계의 핵심 결함을 깨달았다. 즉 내 모델에는 오버로딩을 정밀하게 모델링할 수 있는 제대로 된 교집합 타입(&)이 없었다.
부정확성의 다른 사례는 조건문의 사용에서 나타났다. 다른 유사 타입 시스템과의 흥미로운 차이로, jq에는 Racket의 type?나 Python의 instanceof 같은 네이티브 타입가드가 없다. 타입을 jq에서 정의할 수 없기 때문에 그럴 만하다. 모든 것이 JSON으로 인코딩되기 때문이다. 그래도 JSON에는 다양한 타입이 있고, 우리는 null, bool, number 등을 써 왔다. 그렇다면 사용 중인 값의 JSON 변종을 어떻게 확인할까? 아래는 Rust로 만든 jq 클론인 jaq의 표준 라이브러리에서 가져온 jq로 작성된 type 함수다:
def type:
if . == null then "null"
elif . == false or . == true then "boolean"
elif . < "" then "number"
elif . < [] then "string"
elif . < {} then "array"
else "object"
end;
단순한 3개 값(null, false, true)을 열거하고, 나머지 변종들은 비교 연산자를 사용해 검사한다. jq는 모든 JSON 값을 포괄하는 단순 비교 연산자를 허용하기 때문이다:
null < false < true < <number> < <string> < <array> < <object>
type 함수의 타입은 무엇이어야 할까? 단순하고 “올바른” 타입은 <> -> string이지만, 별로 유용하지 않다. 우리는 계산을 포착하는 타입을 원한다. 여기서 _교집합 타입(intersection types)_이 등장한다. 교집합은 오버로딩을 표현할 수 있게 해 준다. 함수 T1 -> T2 & T3 -> T4는 T1 -> T2 타입도 가지며
T3 ->
T4
타입도 가진다. 따라서 T1과 T3를 모두 전달하면서 정확한 출력 타입을 추적할 수 있다.
그러므로 type 함수의 정밀한 타입은 다음과 같아야 한다:
null -> "null" & bool -> "boolean" & string -> "string" & array -> "array" & object -> "object"
이 결론에 도달하는 건 간단하지 않다. 조건 가드에서 타입 추론을 수행하고, then 분기로 가는 타입과 else 분기로 가는 타입을 구분해야 한다. 타입 시스템의 표현력에 따라 이 분기들의 타입이 같을 수도 있지만, type의 경우 이 기능이 길게 드러난다. 각 가드는 then과 else 분기로 타입 정보를 전달하고, 각 then 분기는 그 시점까지 분기 프로그램에서 밟아 온 경로에 기반해 가능한 타입 공간에서 특정 타입을 검사한다.
이런 제약을 모두 out-of-band 제약 해결 없이 풀 수 있는지도 모르겠다. 나는 개인적으로는 찾지 못했다. 나는 내가 직접 구현한 손수 만든 제약 해결기(hand-wrangled constraint solver)에 기반한 대안적 타입 추론 시스템을 작업하기 시작했다. 프로그램을 따라가며 타이핑 제약을 만들고 병합하는 대신, 나중에 한 번에 전부 푸는 별도의 제약들을 만든다. 이 제약들 중 일부는 꽤 단순하다:
pub fn compute_shape(
f: &Filter,
ctx: &mut Context,
input_type: usize,
output_type: usize,
filters: &HashMap<String, Filter>,
) -> Constraints {
…
Filter::Pipe(f1, f2) => {
let mid_type = ctx.fresh();
let mut cs = vec![];
cs.extend(compute_shape(f1, ctx, input_type, mid_type, filters));
cs.extend(compute_shape(f2, ctx, mid_type, output_type, filters));
cs
}
여기서 입력 타입 T1과 출력 타입 T2로 시작하고, 파이프의 첫 부분 결과를 위한 새로운 타입 T3를 만든다. 그러면 파이프 expr1 | expr2에 대해 타입은
expr1: T1 ->
T3
그리고 expr2: T3 -> T2여야 한다. 다른 연산자들에 대해서는 타입 시스템 안의 명시적 타입을 두고 추론해야 한다:
Filter::UnOp(un_op, filter) => {
// let output_type = ctx.fresh();
let mut cs = compute_shape(filter, ctx, input_type, output_type, filters);
match un_op {
UnOp::Neg => {
// input type must be a number
cs.push(Constraint::Rel {
t1: Shape::TVar(input_type),
rel: Relation::Subtyping(Subtyping::Subtype),
t2: Shape::Number(None),
});
// output_type must be a number
cs.push(Constraint::Rel {
t1: Shape::TVar(output_type),
rel: Relation::Subtyping(Subtyping::Subtype),
t2: Shape::Number(None),
});
}
}
cs
}
단항 음수 연산자는 숫자에만 동작하므로, 입력 타입 T1과 출력 타입 T2가 모두 number의 부분타입이어야 한다고 추론한다(즉 number<3> 같은 구체 숫자거나 임의의 미지 숫자일 수 있다).
내가 쓴 특별한 트릭 하나는 부정확성을 없애기 위한 상수 실행(constant execution)이다. 주어진 필터가 입력을 사용하지 않고도 계산 가능하다면 예를 들어 3 | - .는 -3이 되는데, 이때는 결과를 그냥 계산해 그 구체 값을 타입으로 사용한다. 그러면 이 계산에서 Number 같은 부정확한 타입을 만들 필요가 없다.
이 모든 작업은 아직 진행 중이다(요즘은 논문 마무리를 하느라 잠시 휴면 상태이긴 하지만). tjq 저장소에서 확인할 수 있다. 몇 주 전에는 정적 분석을 보강하기 위해 시도해 볼지도 모르는 대안적 타입 재구성(type reconstruction) 알고리즘도 생각해 냈다. 이런 게 흥미롭다면 연락해 달라. 아주 알파 단계의 도구를 써 보도록 도와 줄 수도 있고, 실패가 있으면 24/7 지원 라인을 제공할 수도 있다. 아니면 최소한 시스템의 어떤 세부든 궁금한 것을 설명해 줄 수 있다.
이 글에서 하나라도 가져가야 한다면, 나는 우리가 언어에서 타입을 활용할 수 있는 잠재력에 비해 아직 한참 멀었다고 말하고 싶다. 우리는 너무 많은 걸 테이블 위에 남겨 두고 있다. TypeScript나 Python 타입 힌트처럼 시스템에 어노테이션을 추가하는 침투적인 점진적 타이핑이 아니더라도, 타입을 추론해 오류를 사전에 막고 더 나은 메시지를 제공할 수 있다. 사용자의 워크플로를 거의 방해하지 않으면서도 사용자를 더 나은 곳으로 이끌 수 있다.