# 0-Y 실측 — 재귀 제네릭 반환 타입은 Luau 상위 한계, quad가 지금 할 수 있는 것 없음

> **[2026-08-13 열세 번째 세션 확정] 이 실측으로 `question.md` 0-Y는
> 해소됐습니다.** 사용자 최종 정리:
>
> > "해당 제한이 풀리기 전까지는 명시적 타입바인딩으로 `:` 사용이
> > 강제되나, 그것이 들어오고 난 뒤에는 풀리게 된다. 지금으로써 quad
> > 프로젝트가 타입을 비틀어 해당 시도를 하는 건 전혀 적합하지 않고,
> > 이것은 상위의 Luau의 현 한계다. RFC와 해당 이슈 해결의 수혜를 받게
> > 될 때 해결될 이슈로써, 당장 우리가 할 수 있는 바 없다."
>
> **지금 유효한 설계 규약은 `base/typing-limits.md`가 소스** — 이
> 문서는 그 결론에 이른 실측 근거(`spikes/` 폴더의 실험 전체 + RFC/소스 대조)를 원문
> 그대로 보존하는 audit 기록입니다. 규약만 필요하면 `typing-limits.md`를
> 보고, "왜 그렇게 정해졌나"가 궁금할 때만 이 문서를 여세요.

**이 폴더의 구성**: 이 `REPORT.md`(실측 서술) + `spikes/`(재현 스크립트
전체 — `00`~`43`에 `21b`~`21e` 같은 후속 변형이 더해짐, 정확한 개수는
폴더 자체가 소스라 여기서 세지 않음). `.claude/audit/`의 다른 기록과
달리 스크립트를 같이 두는 이유는
이 건의 근거가 "여러 formulation을 서로 대조한 것"이라 개별 파일을
직접 돌려봐야 판정이 재현되기 때문입니다(`luau-analyze spikes/<파일>`,
추론된 실제 타입까지 보려면 `luau-analyze --annotate spikes/<파일>`).

**⚠️ 이 리포트는 한 번 크게 정정됐습니다.** 초안은 "쪼개기(split-type)
트릭으로 문제가 완전히 풀렸다"고 결론 내렸는데, 사용자가 "`23` 아래에
체이닝을 더 넣어봤더니 `error type`이 나온다"고 지적한 걸 계기로
`--annotate`(실제 추론된 타입을 눈으로 확인하는 옵션)와 `luau-lsp`로
교차검증했더니 **그 "성공"이 진짜 성공이 아니라 타입 체커가 조용히
포기한 것**이었음이 드러났습니다. 아래 내용은 그 재검증을 반영한
최종본입니다. 실험 파일 `00`~`25`는 초안 때 만든 것, `26`~`35`는 이
정정 과정에서, `36`~`43`은 사용자의 후속 질문(명시 제네릭 인자/미래
대비)에 답하며 추가한 것입니다.

**재현 방법 세 갈래** — 이 문서의 모든 판정은 셋 다로 교차확인했습니다:
1. `luau-analyze spikes/<파일>` — 진단만 봄
2. `luau-analyze --annotate spikes/<파일>` — **추론된 실제 타입을 소스에
   찍어줌. 이 건에서 결정적이었던 도구** — 진단이 0건이어도 타입이
   `Unifiable<Error>`인 경우를 잡아낸 게 이것뿐이었음
3. `luau-lsp analyze --flag:LuauSolverV2=true spikes/<파일>` — 에디터가
   실제로 쓰는 언어서버로 교차검증(1.69.0으로 확인)

**실측 환경(2026-08-13)**: `luau`/`luau-analyze`는 mise 설치본,
grounding용으로 `luau-lang/luau` HEAD(`c73bb37`, 2026-08-12)를 clone해
파서/솔버 소스를 직접 확인, 교차검증용으로 `luau-lsp` 1.69.0 바이너리
사용. **clone/바이너리는 세션 종료 시 삭제했습니다**(24M+16M, 레포에
남길 이유 없음) — 재현이 필요하면 같은 출처에서 다시 받으면 됩니다.

## TL;DR

0-Y는 사실 **완전히 독립된 두 문제**였습니다:

- **문제 A — 콜백 파라미터(self) 추론.** `state:Compute(fn)`에서 `fn`의
  파라미터 `s`가 무주석일 때 타입이 안 잡히는 문제. **이건 진짜
  풀립니다** — `State<T>`를 "Get만 있는 자기참조 데이터부"와 "Compute
  등 로컬 제네릭 메소드부"로 쪼개면 됩니다. 파라미터 쪽은 실제로
  존재하지 않는 필드에 접근하면 정상적으로 에러가 나는 등, **진짜
  타입체크가 살아있는 채로** 무주석 람다가 통과합니다.
- **문제 B — `Compute`의 반환값 자체(`State<U>`).** `Compute`가 원래
  타입 T와 **다른** 타입 U로 새 State를 만들어 반환하는 것 — 즉
  `:Compute`의 존재 이유 그 자체 — 는 **어떤 방법을 써도(쪼개기든,
  파라미터/리턴 둘 다 명시 주석이든, `question.md`가 원래 "완전
  클린"이라 적어뒀던 raw 값 계약이든) 실제로는 타입이 안전하게 추론되지
  않습니다.** 에러를 내면서 막히는 게 아니라 **`Unifiable<Error>`라는
  내부 에러 타입으로 조용히 샙니다** — 겉보기엔 통과한 것처럼 보이고,
  그 결과를 엉뚱한 타입에 대입해도 아무 에러가 안 뜹니다. 이건
  `luau-analyze`/`--annotate`/`luau-lsp`(새 솔버 강제) 세 경로 전부에서
  동일하게 재현됩니다 — 도구별 차이가 아니라 Luau 자체의 한계입니다.

**즉 사용자가 RFC를 읽고 낸 결론("Luau가 타입으로써 이걸 제공하기
전까지는 이 제약을 그냥 감수해야 한다")이 맞았고, 오히려 이 리포트의
초안이 낙관적으로 틀렸습니다.** 다만 실측해보니 그 제약의 실제 모양은
RFC가 설명하는 것("컴파일 에러로 거부됨")보다 더 나쁩니다 — **거부되는
게 아니라 조용히 타입 안전성을 잃습니다.**

---

## 1. 문제 A — 콜백 파라미터 추론은 실제로 풀림

### 최소 재현과 원인

```lua
export type State<T> = {
	Get: (self: State<T>) -> T,
	Compute: <U>(self: State<T>, fn: (self: State<T>) -> U) -> State<U>,
}
local state: State<number> = ...
local doubled = state:Compute(function(s) return s:Get() * 2 end) -- ❌
```

2×2 매트릭스(`02`~`04`)로 좁혀보면, **T가 제네릭인지는 무관하고,
`Compute`가 자기 로컬 제네릭 `U`를 가진 채로 재귀 타입의 필드로 선언돼
있다는 것 자체**가 원인입니다. `Compute`를 필드가 아니라 자유 함수로
빼면 재귀가 있어도 없어도 무주석 람다가 바로 통과합니다 — **단, 이건
self 자신이 모노몰픽(비제네릭)일 때만입니다**(`07`: `self: Box`,
`10`: `self: Box3`, 둘 다 통과). **[2026-08-15 정정,
`/code-review` 지적]** `06`은 원래 이 목록에 같이 묶여 "통과"로
서술됐으나, `06`은 `self: Box<A>`처럼 **self 자신도 제네릭**이라
사실은 실패합니다(`--annotate`로 보면 `s`가 `{value:c}`류 duck 타입,
곱셈에 `Consider annotating the return` `TypeError`까지 남) — 즉
`06`은 07/10이 아니라 아래 `13`(self가 완전 자유 제네릭이면 여전히
안 풀림)과 같은 편입니다. 자유 함수로 빼는 것 자체가 아니라 **"self가
비제네릭인가"가 진짜 분기점**입니다. `:` 메소드 문법 자체는
무관(`05`)하며, 리턴 타입 주석만으로는 안 풀리고(`09`/`12`), self를
완전 자유 제네릭으로 열어도 안 풀립니다(`13`, `06`도 같은 클래스).
후속 조사(`audit/type-recursive-issue-try-callback/REPORT.md` 1번 절)가
이 결론을 "제네릭이 관여하는 함수 호출 인자로 넘긴 함수 리터럴엔
컨텍스트 타입이 전파 안 됨"으로 더 일반화해 재확인했습니다.

### 회피책 — "데이터부/메소드부" 쪼개기 (`14`)

```lua
export type StateData<T> = {
	Get: (self: StateData<T>) -> T,   -- 자기 자신만 재참조(Compute 모름)
}
export type State<T> = StateData<T> & {
	Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
	-- self/콜백 파라미터 둘 다 StateData<T> 사용
}
```

**이 쪼개기의 파라미터 쪽 효과는 진짜입니다** — `29`에서 확인: 콜백
파라미터로 존재하지 않는 메소드/필드를 부르면 정상적으로
`TypeError: Key 'NoSuchMethod' not found in table 'StateData<number>'`가
납니다. 코드 생성(사용자가 제안한 "T별로 구워서 인라이닝") 없이,
`State<T>`가 여전히 진짜 제네릭인 채로 타입 선언 두 개만 손으로 쓰면
됩니다.

**캐비엇**: 콜백이 받는 `s`는 `StateData<T>`라 `Compute`/`With`가 없어서,
콜백 "안에서" 또 `s:Compute(...)`를 부르는 중첩은 안 됩니다(`20`).
`:Apply`의 factory처럼 콜백이 원본의 전체 능력이 필요한 자리는 이
쪼개기로 못 풀고 `function(self: State<T>)`처럼 파라미터 타입 주석이
계속 필요합니다(`21`, `21b`~`21e`는 전부 실패, `22`는 주석 추가로 해결
— 다만 base 문서가 이미 `:Apply`엔 이름 붙인 재사용 팩토리를 권장해서
실질 손해는 작아 보임). `Effect(fn, state)`(자유 함수)와
`state:Observer(fn)`(로컬 제네릭 반환 없음)는 애초에 이 문제와 무관—
지금 그대로도 무주석으로 잘 됩니다(`25`).

## 2. 문제 B — `Compute`의 반환 타입은 조용히 안전성을 잃음 (`26`~`35`, 정정의 핵심)

### 어떻게 발견했나

`23`(쪼개기 적용한 `With`+`Compute` 실사용 예시)에 사용자가 체이닝을 더
붙여보고 에디터에서 `error type`을 봤다고 알려준 게 계기였습니다.
`luau-analyze`로는 클린 통과였는데 왜 에디터는 다르게 보였는지
확인하려고 `--annotate`(실제 추론된 타입을 소스에 그대로 찍어주는 옵션)를
써봤더니:

```lua
-- luau-analyze --annotate 14-split-intersection-compute-outside.luau
local doubled:Unifiable<Error>=state:Compute(function(s:StateData<number>): number return s:Get()*2 end)
```

**에러가 하나도 안 떴던 그 "통과"가 사실 `Unifiable<Error>`(Luau
내부의 미해결/에러 타입 placeholder)였습니다.** 겉보기엔 성공한 것처럼
보였을 뿐, `U`(=`Compute`가 반환하는 `State<U>`의 U)가 실제로는 전혀
해소되지 않은 채였습니다.

### 진짜 위험한 부분 — 타입 안전성이 조용히 사라짐

`27`/`28`로 직접 확인: `s`가 `State<string>`이어야 하는데 **명백히 틀린
타입에 대입해도 에러가 안 납니다**:

```lua
local s = n:Compute(function(x) return tostring(x:Get()) end) -- s는 State<string>이어야 함
local wrong: number = s:Get()   -- ❌이어야 하는데 통과함(exit=0, 에러 없음)
```

LHS에 일부러 틀린 타입을 명시해도(`28`, `local wrongLHS: State<boolean> =
n:Compute(function(x) return tostring(x:Get()) end)`) 마찬가지로 통과.
**`luau-lsp analyze --flag:LuauSolverV2=true`로도 동일하게 재현**돼서
`luau-analyze` CLI만의 문제가 아님을 확인했습니다.

### 이게 "쪼개기"만의 문제가 아님 — 모든 formulation에서 동일

가장 중요한 재확인: 이 불안전성은 **문제 A와 무관하게, `Compute`가
`State<U>`(다른 파라미터로 재귀하는 반환)를 만드는 한 어떤 방식을 써도
똑같이 재현**됩니다.

| 실험 | 형태 | 결과 |
|---|---|---|
| `28` | 쪼개기 + 무주석 | Unifiable<Error>, 틀린 대입도 통과 |
| `30` | 쪼개기 + 콜백 **리턴 타입만** 명시 | 동일하게 안전 안 됨 |
| `31` | **쪼개기 없이** 파라미터/리턴 **둘 다 명시**(=question.md 선택지 1) | 동일 |
| `34` | **raw 값 계약**(=question.md 선택지 2, 원래 "완전 클린"으로 알려져 있던 것) | 동일 |

`34`가 특히 중요합니다 — 원래 audit 문서(`luau-test-first-run-2026-08-13.md`)가
"콜백이 raw 값 T를 받으면 완전 클린(0건)"이라고 적어둔 바로 그
케이스인데, 그건 **"에러가 안 뜬다"만 확인했을 뿐 반환 타입이 진짜
해소됐는지는 확인한 적이 없었습니다.** 이번에 `--annotate`로 열어보니
`34`도 똑같이 `Unifiable<Error>`이고 틀린 대입이 잡히지 않습니다 —
**즉 원래 0-Y 표에 있던 "raw 값 = 완전 클린"이라는 판정 자체가 이번
실측으로 뒤집힙니다.**

### 정확한 경계 — "재귀"가 아니라 "다른 파라미터로 재귀"가 원인

세 가지 대조군으로 정확히 좁혔습니다:

- `33`: `Compute`가 **같은** T로 재귀하는 `State<T>`를 반환(로컬 제네릭
  `U` 없음, matrix B와 동일 모양) → **진짜 sound**. `doubled:State<number>`로
  정확히 추론되고, 틀린 대입은 실제로 에러가 남.
- `35`: `Compute`가 로컬 제네릭 `U`를 **재귀하지 않는 평범한 컨테이너**
  `Box<U>`에 담아 반환 → **진짜 sound**. `s:Box<string>`로 정확히
  추론됨.
- `07`/`32`: `Compute`가 `U`를 **감싸지 않고 그대로** 반환(래핑 자체가
  없음) → **진짜 sound**.

→ **"제네릭으로 감싸서 반환하는 것" 자체는 문제가 아닙니다. 문제는
정확히 "자기 자신과 같은 이름의 재귀 타입을, 자기 타입 파라미터와
다른 인자로 다시 감싸서 반환하는 것"** — 즉 `Compute<U>(self: State<T>,
...) -> State<U>`에서 `State<U>`가 `State<T>`와 다른 인자로 자기
자신을 재참조하는 바로 그 자리입니다. 이건 사용자가 처음 찾아준 RFC
(`relax-recursive-type-restriction.html`)가 `Promise<T>.andThen`으로
예시 들었던 바로 그 패턴이고, `Promise<T>.andThen`도 이 패턴과 100%
동일한 모양입니다.

### RFC와의 관계 — "완화"가 아니라 "거부만 안 할 뿐, 안 풀림"

초안에서는 "새 솔버가 이 제약을 이미 완화했다"고 썼는데, 그건 **딱
선언(타입 alias 자체를 적는 것)까지만** 맞는 얘기였습니다(`17`/`18`이
확인한 게 이것 — 선언은 통과함). 그런데 이번에 **실제 호출부에서 U가
진짜로 풀리는지**까지 확인해보니 안 풀립니다 — 옛 솔버는 이 패턴을
선언 시점에 아예 거부해서 "이 기능을 못 쓴다"는 게 최소한 명확한 반면,
**새 솔버는 선언은 받아주고 사용도 허용하지만, 실제로는 조용히 타입
안전성만 잃습니다.** RFC가 제안한 완화 메커니즘("타입 별칭을 진짜
type function처럼 취급해 lazy expansion")이 선언 검사 단계까지만
반영되고, 실제 인스턴스화/단일화 단계는 아직 안 풀린 것으로 보입니다
— 웹서치로 찾은 관련 이슈(`luau-lang/luau#2380` "Allow recursive
generic types to differ", 아직 열려 있고 메인테이너 답변 없음)도 이
영역이 계속 진행 중인 작업임을 뒷받침하지만, **"조용히 Unifiable<Error>로
샌다"는 이번에 직접 관측한 증상 자체를 정확히 다루는 이슈는 못
찾았습니다** — GitHub 검색을 더 깊게 하진 않았습니다.

### 딱 하나 더 본 것 — 깊은 체인에서의 거동 (`26`)

10단 이상 `Compute`를 체이닝(number→string→number→boolean→...)해보면,
한 번 `Unifiable<Error>`로 오염된 뒤에는 **항상 조용히 통과하는 것도
아닙니다** — 몇 단계 뒤에서 `#x:Get()`(문자열 길이 연산)처럼 실제
연산자 제약과 부딪히면 `TypeError: Expected type table, got 'a'
instead`처럼 **뜬금없는 자리에서 에러가 남**(정작 문제의 근원인 앞
단계 Compute 호출 줄이 아니라 훨씬 뒤에서). 즉 이 불안전성은 "항상
조용히 무시됨"도 "항상 에러남"도 아니고, **예측하기 어려운 지점에서
간헐적으로만 진짜 에러가 튀어나오는** 가장 다루기 힘든 형태입니다.

## 3. 종합

| API | 문제 A(파라미터 추론) | 문제 B(반환 타입 안전성) |
|---|---|---|
| `state:Compute(fn)` | ✅ 쪼개기로 해결 | ❌ **어떤 방법으로도 해결 못 함** |
| `state:With(...)` | ✅ 쪼개기로 해결(이형 dep 포함) | 반환이 결국 `Compute`로 이어지면 동일하게 영향 |
| `Effect(fn, state)` | 원래도 무관 | 반환이 없으므로 무관 |
| `state:Observer(fn)` | 원래도 무관(로컬 제네릭 없음) | 반환이 `EffectHandle`(재귀 아님)이라 무관 |
| `state:Apply(factory)` | 파라미터 주석 필요(2절 캐비엇) | factory가 결국 `:Compute`를 부르면 동일하게 영향 |

**핵심 결론**: `question.md` 0-Y가 "콜백 파라미터가 lazy 핸들을 받는
계약" 문제로 프레이밍했던 것 중, **콜백 파라미터 쪽(문제 A)은 진짜
풀 수 있는 문제였고 쪼개기로 실제로 풀립니다.** 하지만 **`:Compute`가
새 타입을 반환한다는 것 자체(문제 B)는 이 세션에서 시도한 그 무엇으로도
못 풀었고, 이건 애초에 0-Y가 "선택지 1/2/3" 프레임으로 물었던 질문의
범위 밖에 있는 더 근본적인 제약**입니다 — 콜백이 lazy 핸들을 받든 raw
값을 받든 무관하게 똑같이 안 풀립니다.

### 3-1. 실효 대응책 — 명시적 타입 바인딩은 다운스트림에 진짜로 먹힘 (`43`)

**추론이 못 주는 타입을 사람이 `:`로 직접 박아주면, 그 자리부터
아래로는 타입 체크가 정상 작동합니다.** `28`이 "LHS 주석은 RHS를
검증해주지 않는다"를 보여줬기 때문에 처음엔 주석이 무의미해 보였는데,
그 둘은 별개 질문이라 `43`으로 따로 확인했습니다:

```lua
local s: State<string> = n:Compute(function(x) return tostring(x:Get()) end)

local ok:  string = s:Get()      -- ✅ 통과
local bad: number = s:Get()      -- ✅ 에러남! Expected 'number', got 'string'
local bad2 = s:NoSuchMethod()    -- ✅ 에러남! does not have key 'NoSuchMethod'
```

즉 정확히 이렇게 갈립니다:

| 구간 | 체크되는가 |
|---|---|
| 콜백 **안**(원본 값을 어떻게 다루는지) | ✅ 진짜 체크됨(`29`) |
| `Compute` 호출문 자체(RHS가 LHS 주석과 맞는지) | ❌ **안 됨** — 여기가 유일한 구멍 |
| 명시 주석 **이후** 다운스트림 전체 | ✅ 진짜 체크됨(`43`) |

**그래서 실무 규약은 "파생 State를 만드는 자리마다 결과 타입을 명시
주석으로 박는다"** 가 됩니다 — 구멍이 "그 한 줄이 실제로 그 타입을
만드는지"로 좁혀지고, 나머지 코드베이스 전체는 정상적으로 타입
안전해집니다. 잃는 것은 그 한 줄의 자동 검증뿐입니다.

### 3-2. 최종 결론

**이건 quad가 설계로 풀 문제가 아니라 상위 Luau의 현 한계입니다.**
`:Compute`가 `State<U>`를 반환하는 것은 리액티브 파생값 API의 본질
그 자체이고(RFC 자신이 `Promise<T>.andThen`을 같은 모양의 대표
사례로 듦), 이걸 피하려고 타입을 비트는 것(모노모픽 코드 생성, 반환
타입을 `any`로 열기, API 모양 자체를 바꾸기 등)은 **RFC가 고쳐줄
대상에서 스스로 이탈하는 것**이라 지금 이득보다 나중 부채가 큽니다.

- **지금**: 명시적 타입 바인딩(`:`)이 강제됨 — 위 3-1의 규약.
- **RFC/이슈가 해결되면**: 지금 그대로의 선언이 **코드 변경 없이**
  자연히 풀림(아래 6-2절 근거).
- **당장 우리가 할 수 있는 바 없음** — 문서화(`base/typing-limits.md`)와
  추적 링크(`luau-lang/luau#2380`)가 전부.

## 4. 아직 안 본 것

- `Unifiable<Error>`가 뜨는 정확한 내부 매커니즘(어느 제약 해소 단계에서
  포기하는지)은 Luau 소스코드 레벨까지는 안 팠습니다 — grounding은
  RFC 2건 + 대조 실험 + 관련 이슈 1건(`#2380`, 못 닫힘)까지만.
- 이 불안전성을 근본적으로 피하는 **다른 API 설계**(예: `:Compute`가
  `State<U>`를 반환하는 대신 다른 무언가를 반환하는 방식)는 탐색하지
  않았습니다 — **[2026-08-13 사용자 확정] 앞으로도 탐색 안 함**:
  "타입을 비틀어 해당 시도를 하는 건 전혀 적합하지 않고, 이것은
  상위의 Luau의 현 한계다". 이 항목은 열린 숙제가 아니라 **명시적으로
  범위 밖**입니다(3-2절, `base/typing-limits.md`).
- `previous?`/trailing deps 제네릭 팩(`rewrite-required/15`)도 같은
  이유(Compute가 결국 `State<U>`를 반환)로 똑같이 영향받을 걸로
  보이지만 별도 검증은 안 했습니다.
- `luau-lsp`의 `enableNewSolver` 설정과 Roblox 전역 타입 정의
  (`--definitions`)의 상호작용은 `--platform=standard`(정의 파일 없이)
  환경에서만 확인했습니다.

## 5. 실무 캐비엇 — 에디터(`luau-lsp`)는 기본이 옛 솔버

`luau-analyze` CLI는 새 솔버가 기본값이지만, 실제 에디터가 물고 있는
`luau-lsp`(최신 릴리즈 1.69.0으로 직접 대조)는 **기본값이 옛
솔버**(`LuauSolverV2=false`)입니다. 옛 솔버로는 문제 B의 패턴
자체가 선언 시점에 거부되므로(오히려 안전한 실패), **이 문서가 다루는
쪼개기 등을 실제로 눈으로 확인하려면 에디터 쪽 설정도 새 솔버로
맞춰야** 합니다:

```json
{ "luau-lsp.fflags.enableNewSolver": true }
```

VSCode라면 워크스페이스 `.vscode/settings.json`에 넣으면 팀 전체가
공유됩니다. 다만 위에서 정리했듯 **새 솔버로 맞춰도 문제 B는 여전히
안 풀리고 조용히 위험해질 뿐**이라, 이 설정을 켜는 것 자체가 안전을
보장해주진 않습니다.

**이 선택은 아직 안 정해졌습니다(열린 항목)** — 두 방향의 트레이드오프가
실측으로는 이렇게 갈립니다:
- **새 솔버(V2)로 맞춤**: 문제 A의 쪼개기가 작동해 무주석 콜백을 쓸 수
  있음. 대신 문제 B가 조용한 구멍으로 남음.
- **옛 솔버 유지(현재 luau-lsp 기본값)**: 문제 B 패턴을 선언 시점에
  아예 거부 — "조용한 구멍" 대신 "명확한 실패". 대신 재귀 제네릭
  선언 자체를 못 쓰므로 `Compute`의 지금 시그니처가 성립 안 함.

즉 옛 솔버는 "안전하지만 이 API를 아예 못 만듦"이고, 새 솔버는 "만들 수
있지만 한 줄이 검증 안 됨"이라 **실질적으로는 새 솔버 외에 선택지가
없어 보입니다** — M0 실착수 때 실제 에디터 환경에서 확정할 것
(`base/typing-limits.md`의 "미해결" 절에 추적).

## 6. 후속 리서치 — 명시 제네릭 인자, 그리고 미래 대비 설계 (`36`~`42`)

사용자가 리포트 확인 후 두 가지를 추가로 요청: (1) `state:Compute<FromState,
ToState>()`처럼 명시적으로 타입 인자를 넣어주는 방법이 되는지, (2) 지금
당장은 명시 타이핑으로 버티더라도, RFC가 낙관하는 미래 방향이 실제로
들어왔을 때 우리 코드가 **자연히** 그 위에 올라탈 수 있는 형태로
지금부터 짜둘 수 있는지.

### 6-1. `<<...>>` — 진짜 문법이었지만 우리 케이스엔 안 먹힘

**결론부터: 문법은 실재하고 일반적인 제네릭엔 진짜로 작동하지만,
`State<U>`(재귀 self-참조 타입을 다른 파라미터로 반환) 케이스는 여전히
못 고칩니다.**

Luau 소스(`Ast/src/Parser.cpp` `parseMethodCall`)를 직접 열어 확인 —
메소드 호출 뒤에 `<`가 연달아 두 번(`<<`) 오면 `parseTypeInstantiationExpr`로
분기해 명시 타입 인자 리스트를 파싱합니다(단일 `<`는 비교 연산자와
문법이 겹쳐서 안 됨 — 그래서 이중 괄호). 이 정보(`AstExprCall::
typeArguments`)는 새 솔버의 `ConstraintGenerator.cpp`(2818행)에서
실제로 읽혀 `FunctionCallConstraint`까지 전달됩니다 — 죽은 코드가
아니라 진짜 배선돼 있습니다.

**일반 제네릭에선 실제로 작동합니다(`42`)**:
```lua
local function identity<T>(v: T): T return v end
local x = identity<<string>>(5 :: any)
local wrong: number = x -- 진짜 에러남(Expected 'number', got 'string')
```
`--annotate`로도 `x:string`으로 정확히 찍힘 — explicit 제네릭 인자가
진짜로 타입을 확정시킵니다.

**하지만 우리의 `Compute<U>(...)  -> State<U>`에 똑같이 적용해도
안 풀립니다(`40`)**:
```lua
local s = n:Compute<<string>>(function(x) return tostring(x:Get()) end)
-- --annotate: local s:Unifiable<Error>=... (여전히!)
local wrong: number = s:Get() -- 여전히 안 잡힘
```
`U`를 추론이 아니라 **명시로 못박아도** `State<U>`라는 반환 타입 자체가
`Unifiable<Error>`로 새는 건 그대로입니다. 문제 A(파라미터 self 추론)
쪽도 명시 제네릭으로 우회되는지 따로 확인했는데(`41`, 쪼개기 없이 원본
재귀 타입 그대로 + `<<number>>`만 추가) **완전히 무효과** — `00`과
정확히 같은 에러가 그대로 남. `<<...>>`를 아무리 붙여도 파서가 받아만
줄 뿐 타입 체크 결과에 어떤 영향도 못 줍니다(같은 이유로 `39`에서
존재하지 않는 타입 이름을 넣어도 에러가 안 났던 것 — 애초에 우리
케이스에선 이 정보가 실질적으로 안 쓰이고 있다는 뜻).

**해석**: 문제 B는 "U가 무엇인지 모른다"가 아니라 **"U를 알아도
`State<U>`(자기 자신을 다른 파라미터로 재귀 참조하는 타입)를 실제
콘크리트 타입으로 확장(expand)하는 단계 자체가 아직 안 됨"** —
RFC가 말하는 "lazy expansion of type alias contents"가 이 확장
단계를 가리키는 걸로 보이고, 그 부분이 아직 안 들어와서 명시 인자를
줘도 무용지물입니다.

### 6-2. 미래 RFC를 위한 "플레이스홀더" — 필요 없어 보임(좋은 소식)

RFC 원문을 다시 정독: 완화 근거로 "This pays for itself in the
considerable gain in expressivity gained for users of the type
system"(비용을 치를 가치가 있다)는 문장이 있고, **완화 메커니즘은
"타입 별칭을 진짜 type function처럼 취급해 lazy expansion"이라는
순수 내부 솔버 아키텍처 변경**이라고 명시돼 있습니다. RFC가 직접
"이 정의는 지금 거부되지만"이라며 드는 예시가 바로:
```lua
type Promise<T> = {
    andThen: <U>(self: Promise<T>, callback: (T) -> Promise<U>) -> Promise<U>
}
```
— **지금 이 순간에도 우리가 이미 쓰고 있는, 아무 변형도 안 가한
자연스러운 재귀 제네릭 선언 그 자체**입니다. 즉:

- **문법을 바꿔서 미리 대비할 필요가 없습니다** — RFC의 완화가
  들어오면 지금 그대로의 `Compute<U>(self: State<T>, fn) -> State<U>`
  선언이 (또는 우리가 채택한 쪼개기 버전도, `State<T>` 자체는 여전히
  진짜 제네릭 재귀 별칭이므로 마찬가지로) **아무 코드 변경 없이** 자동으로
  올바르게 풀릴 걸로 보입니다.
- **오히려 하지 말아야 할 것**: 사용자가 처음 제안했던 "T별로 코드
  생성해서 미리 구워두기"(모노모picization) 방향으로 갔다면, 그건
  **제네릭 자체를 없애버리는 것**이라 RFC가 고치는 대상(제네릭 재귀
  별칭의 인스턴스화)과 무관해집니다 — 그 경로로 가면 나중에 RFC가
  들어와도 자동으로 득 볼 게 없고, 오히려 코드 생성 인프라를 걷어내는
  별도 마이그레이션이 필요해집니다. **이번 세션에서 코드 생성 없이
  쪼개기만으로 문제 A를 푼 게(1절) 결과적으로 이 방향과도 맞아떨어짐**
  — `State<T>`가 여전히 손으로 쓴 두 개의 제네릭 타입 별칭일 뿐이라,
  RFC가 들어오면 이 쪼개기 자체도 필요 없어질 가능성이 있어 보입니다
  (제거해도 그만, 남겨둬도 그만 — 검증은 필요).
- **지금 할 수 있는 유일한 "대비"는 문서화뿐입니다**: `Compute`(및 같은
  모양을 공유하는 `With`/`Apply`)의 반환 타입이 현재 Luau에서 정적으로
  검증되지 않는다는 걸 base 문서에 캐비엇으로 남겨두고, 이 RFC
  (`luau-lang/luau` 관련 이슈 `#2380`)를 추적 링크로 걸어두는 정도가
  합리적으로 보입니다 — 코드/타입 구조 자체를 미래를 위해 미리
  바꿔둘 이유는 실측상 없어 보입니다.

## 7. 실험 파일 인덱스

`00`~`25`(초안, 문제 A 중심), `26`~`35`(정정, 문제 B 발견), `36`~`43`
(후속 — 명시 제네릭 인자/미래 대비)로 나뉩니다(`21b`~`21e`처럼 뒤에 끼워
넣은 변형은 원래 번호 옆에 붙어 있습니다).
자세한 개별 실험 설명은 각 파일 상단 주석 참고. 핵심만:

| 파일 | 확인 내용 | 결과 |
|---|---|---|
| `00`~`13` | 문제 A 원인 좁히기(매트릭스, 자유함수 대조군 등) | 자기참조 필드+로컬 제네릭이 원인으로 확정 |
| `14` | 쪼개기 최소 재현 | ✅(파라미터 쪽만, 이후 재검증 필요했음이 드러남) |
| `15`~`18` | 문제 B 선언 단계, RFC 대조 | 로컬 제네릭 차이는 선언은 통과(새 솔버) |
| `19`,`23`,`24` | 쪼개기 스트레스(체이닝/중첩/이형 With) | 이후 전부 Unifiable<Error>였음이 드러남(26~ 참고) |
| `20` | 쪼개기 캐비엇(콜백 안 재중첩) | ❌ 예상대로 |
| `21`,`21b`~`21e`,`22` | `:Apply` 파라미터 주석 필요성 | 주석 없이는 불가, 있으면 가능 |
| `25` | `Observer`는 원래 무관 | ✅ |
| `26` | 깊은 체인(10단+) | 간헐적/예측불가 에러 패턴 확인 |
| `27`,`28` | **쪼개기의 반환 타입 불안전성** | ❌ 틀린 대입도 통과 — 정정의 시작점 |
| `29` | 쪼개기의 파라미터 쪽은 진짜 안전한지 재확인 | ✅ 진짜 sound(문제 A는 유효) |
| `30` | 리턴 타입 주석으로 문제 B 우회 시도 | ❌ 안 됨 |
| `31` | 쪼개기 없이 완전 명시(선택지 1) | ❌ 역시 불안전 |
| `32` | 대조군(U를 안 감싸고 직접 반환) | ✅ sound |
| `33` | 대조군(같은 T로만 재귀) | ✅ sound — 정확한 경계 확정 |
| `34` | raw 값 계약(선택지 2, "완전 클린"으로 알려졌던 것) | ❌ 역시 불안전 — 판정 뒤집힘 |
| `35` | 대조군(재귀 아닌 컨테이너) | ✅ sound |
| `36` | 사용자 원래 `<T>` 단일 괄호 시도 재현 | ❌ SyntaxError(`<`는 비교 연산자와 충돌) |
| `37` | 쪼개기 Compute에 `<T>` 단일 괄호 | ❌ 동일 SyntaxError |
| `38`,`39` | `<<T>>` 이중 괄호 최초 확인 | 파싱은 됨(에러 없음) — 근데 존재하지 않는 타입명도 안 걸림(수상함) |
| `40` | `<<...>>`로 문제 B(반환 타입) 우회 시도 | ❌ 여전히 Unifiable<Error> |
| `41` | `<<...>>`로 문제 A(파라미터) 우회 시도 | ❌ 완전 무효과, `00`과 동일 에러 |
| `42` | 대조군: 일반(비재귀) 제네릭에 `<<...>>` | ✅ 진짜 작동함(문법/배선 자체는 확인됨) |
| `43` | **명시 LHS 주석이 다운스트림에 타입을 바인딩하는가** | ✅ **먹힘** — 3-1절, 실효 대응책의 근거 |

전부 `spikes/` 안에 있습니다. 실행 환경/재현 방법은 이 문서 맨 위
"재현 방법 세 갈래" 참고.
