quad/.claude/base/typing-limits.md
qwreey 93f548a2af
docs(type): 0-Y 해소 — 재귀 제네릭 반환은 Luau 상위 한계로 확정, typing-limits.md 신설
44개 스파이크로 question.md 0-Y를 재실측한 결과, 여섯 번째 세션의
"콜백이 raw 값을 받으면 완전 클린" 판정이 틀렸음이 드러남 — 그건 진단
0건만 확인한 것이었고, luau-analyze --annotate로 열어보니 반환 타입이
Unifiable<Error>로 조용히 새고 있었음(틀린 대입도 안 잡힘).

진짜 원인은 콜백 계약이 아니라 Compute가 State<U>(자기 이름을 다른 타입
인자로 감싼 타입)를 반환한다는 것 자체 — RFC relax-recursive-type-restriction이
Promise<T>.andThen으로 예시 든 바로 그 패턴. 사용자 확정: quad가 타입을
비틀 일이 아니라 상위 Luau의 현 한계이고, RFC/이슈 수혜를 받을 때 해결될
일이라 당장 할 수 있는 바 없음.

- base/typing-limits.md 신설 — 흩어져 있던 타입 한계 5건 통합, 대전제
  "Luau 한계를 우회하려 타입/API를 비틀지 않는다", 새 API 설계 체크리스트
- audit/type-recursion-issue/ 신설 — REPORT.md + spikes 44개(audit 폴더에
  스크립트를 같이 둔 첫 예외, 판정 재현에 개별 실행이 필요해서)
- 0-Y 해소 전파: question.md(최우선 2건→1건) / archive / base 5개 /
  research 2개 / 인덱스 4개 / luau-test(08을 done/으로, review-required 비움)
- audit/luau-test-first-run-2026-08-13.md: 판정이 뒤집힌 당사자라 배너뿐
  아니라 본문 표·문단·결론까지 전수 수정
- HUMAN_TODO 6번 신설: luau-lsp 기본이 옛 솔버라 CLI와 진단이 다름

교훈: luau-analyze 진단 0건은 타입 해소를 뜻하지 않음 — 타입 스파이크는
--annotate로 실제 추론 타입을 확인하고 음성 대조군을 같이 둘 것.

doc-check.py ERROR 0 유지(WARN 59건, 변경 전과 동일).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KckSawrsJSJmDBcSojJPxZ
2026-08-13 22:08:51 +09:00

298 lines
16 KiB
Markdown

# 타입 시스템의 한계와 그 대응 — quad 전역 규약
**이 문서는 "Luau 타입 시스템이 quad 설계에 대해 못 해주는 것"을 한
군데 모은 확정 문서입니다.** 각 한계마다 (a) 정확히 무엇이 안 되는가,
(b) 그래서 우리가 코드/문서에서 뭘 해야 하는가, (c) 언제/어떻게 풀릴
전망인가를 적습니다.
**왜 따로 모으는가**: 이 한계들은 여러 `base/` 문서에 흩어져 각자
캐비엇으로 붙어 있었고, 그러다 보니 (1) 새 API를 설계할 때 같은 벽에
매번 새로 부딪히고, (2) "이건 우리 설계 문제인가 Luau 문제인가"를
매번 다시 판정하고, (3) 나중에 Luau가 고쳐줬을 때 **어디를 되돌려야
하는지** 알 수 없었습니다. 2026-08-13 열세 번째 세션에 가장 큰 한 건
(아래 1번)이 확정되면서 같이 정리했습니다.
---
## 0. 대전제 — Luau의 한계를 우회하려고 타입/API를 비틀지 않는다
**[2026-08-13 확정, 사용자]**
> "지금으로써 quad 프로젝트가 타입을 비틀어 해당 시도를 하는 건 전혀
> 적합하지 않고, 이것은 상위의 Luau의 현 한계다. RFC와 해당 이슈
> 해결의 수혜를 받게 될 때 해결될 이슈로써, 당장 우리가 할 수 있는
> 바 없다."
이 원칙이 아래 모든 항목에 우선합니다. 구체적으로:
- **API의 자연스러운 모양을 타입 사정으로 바꾸지 않는다.** 리액티브
파생값 API가 `Compute<U>(...) -> State<U>` 모양인 건 본질이고,
Luau가 지금 그걸 못 다룬다고 해서 반환 타입을 `any`로 열거나,
API를 다른 모양으로 재설계하거나, 제네릭을 포기하고 타입별 코드를
생성하는 건 **전부 하지 않습니다.**
- **이유는 "게을러서"가 아니라 "그게 더 비싸서"입니다.** 지금 비틀어
놓으면 (a) 비튼 만큼 복잡도와 사용자 인체공학 손해가 영구히
남고, (b) Luau가 고쳐졌을 때 자동으로 수혜를 받는 게 아니라 비튼
걸 되돌리는 별도 마이그레이션이 필요해집니다. 아래 1번의 실측이
이걸 구체적으로 보여줍니다 — **지금 그대로 두면 Luau 쪽 수정만으로
코드 변경 없이 풀립니다.**
- **대신 반드시 하는 것**: (1) 한계를 이 문서에 명시, (2) 사용자
코드가 취해야 하는 관례를 명시, (3) 추적 링크(RFC/이슈)를 남겨
나중에 되돌릴 지점을 알 수 있게 함.
**예외**: 그 한계를 우회하는 방법이 *비트는 게 아니라 그냥 더 나은
설계*인 경우엔 당연히 채택합니다(아래 1번의 "쪼개기"가 실제로 그런
사례 — 코드 생성 없이 타입 선언 두 개로 끝나고, 나중에 Luau가
고쳐져도 손해가 없음).
---
## 1. ⭐ 재귀 제네릭이 다른 타입 인자로 자기를 반환하면 타입 안전성이 조용히 사라짐
**실측 근거: `audit/type-recursion-issue/`**(REPORT.md + 재현 스파이크
44개). 이게 이 문서에서 가장 크고, 가장 넓게 영향을 주는 항목입니다.
### 무엇이 안 되는가
`Compute<U>(self: State<T>, fn) -> State<U>`처럼 **자기 자신과 같은
이름의 재귀 타입을, 자기 타입 파라미터(`T`)와 다른 인자(`U`)로 다시
감싸서 반환**하면, 반환 타입이 실제로는 해소되지 않고 Luau 내부의
`Unifiable<Error>`**조용히** 샙니다.
**"조용히"가 핵심입니다** — 컴파일 에러가 나서 막히는 게 아니라,
진단 0건으로 통과한 뒤 그 결과에 대한 타입 체크만 사라집니다:
```lua
local s = n:Compute(function(x) return tostring(x:Get()) end) -- s는 State<string>이어야 함
local wrong: number = s:Get() -- ❌이어야 하는데 에러 안 남
```
`luau-analyze` / `luau-analyze --annotate` / `luau-lsp`(새 솔버)
세 경로 전부 동일 — 도구 문제가 아니라 Luau 자체의 현 한계입니다.
### 정확한 경계 (오해 방지 — 이것들은 멀쩡함)
실측으로 좁힌 결과 **"제네릭을 감싸서 반환하는 것" 자체는 문제가
아닙니다.** 아래는 전부 정상 작동합니다:
| 패턴 | 상태 |
|---|---|
| 같은 인자로만 재귀(`-> State<T>`, `U` 없음) | ✅ 정상 |
| 재귀 아닌 컨테이너에 담아 반환(`-> Box<U>`) | ✅ 정상 |
| 감싸지 않고 그대로 반환(`-> U`) | ✅ 정상 |
| 콜백 **파라미터**의 타입 추론 | ✅ 정상(아래 "쪼개기" 적용 시) |
| 콜백 **안**의 로직(원본 값을 어떻게 다루는지) | ✅ 정상 |
| 명시 주석 **이후** 다운스트림 전체 | ✅ 정상 |
| **자기 이름을 다른 인자로 감싸 반환**(`State<T>` → `State<U>`) | ❌ **이것만** |
즉 구멍은 정확히 **"그 한 줄이 진짜 그 타입을 만드는가"** 하나로
좁혀집니다.
### 그래서 우리가 하는 것 — ① 명시적 타입 바인딩 강제
**파생 State를 만드는 자리마다 결과 타입을 `:` 주석으로 명시합니다.**
```lua
-- ✅ 이렇게
local label: State<string> = count:Compute(function(c) return tostring(c:Get()) end)
-- ❌ 이렇게 두면 이후 코드 전체의 타입 체크가 사라짐
local label = count:Compute(function(c) return tostring(c:Get()) end)
```
주석이 그 한 줄의 RHS를 검증해주진 않지만(그게 위의 구멍),
**다운스트림에는 정확히 바인딩됩니다** — 실측으로 확인:
잘못된 사용(`local bad: number = label:Get()`)도, 없는 메소드
호출(`label:NoSuchMethod()`)도 정상적으로 에러가 납니다. 그래서
"한 줄만 못 믿고 나머지 코드베이스 전체는 안전"한 상태가 됩니다.
이건 **API 문서/예제/튜토리얼에도 그대로 반영해야 하는 관례**입니다
(사용자가 무주석으로 쓰면 조용히 타입 안전성을 잃으므로) —
`research/documentation-plan.md`/`documentation-content-map.md`가
문서 작성에 들어갈 때 이 관례를 초심자 트랙에 넣을 것.
### 그래서 우리가 하는 것 — ② 타입 선언은 "데이터부/메소드부" 쪼개기
위 1번과 별개로, **콜백 파라미터가 무주석일 때 추론이 안 되는 문제**는
진짜로 풀립니다. 원인은 "로컬 제네릭 `U`를 가진 메소드가 자기를
재귀 참조하는 타입의 필드로 선언돼 있다"는 것이고, 그 필드가 참조하는
self 타입에서 자기 자신(`Compute`)을 빼면 됩니다:
```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>를 가리킴
}
```
- 이러면 `state:Compute(function(s) return s:Get() * 2 end)`
**무주석으로 통과**하고, `s`에 대한 타입 체크도 진짜로 살아있습니다
(없는 필드 접근하면 정상적으로 에러남).
- **코드 생성이 필요 없습니다.** `State<T>`는 여전히 진짜 제네릭이고,
손으로 쓰는 타입 선언이 하나 늘 뿐입니다. (한때 검토했던 "T별로
구워서 인라이닝"은 채택 안 함 — 0번 대전제 위반이고, 제네릭을
없애버려서 나중에 Luau가 고쳐져도 수혜를 못 받음.)
- **캐비엇**: 콜백이 받는 `s``StateData<T>``Compute`/`With`가
없습니다. 콜백 안에서 다시 `s:Compute(...)`를 부르는 자리
(`:Apply`의 factory가 대표적)는 이 방식으로 못 풀고
`function(self: State<T>)`처럼 파라미터 주석이 필요합니다 —
다만 `:Apply`는 이미 "이름 붙인 재사용 팩토리"를 권장하는 자리라
(`tween-plan.md`의 `Animate`, `research/operator-sugar-plan.md`
`Sum`류) 그런 팩토리는 최상위 함수 선언이라 자연히 주석을 답니다.
### 영향 범위
| API | 파라미터 추론 | 반환 타입 안전성 |
|---|---|---|
| `state:Compute(fn)` | 쪼개기로 해결 | ❌ 명시 바인딩 필요 |
| `state:With(...)` | 쪼개기로 해결(이형 dep 포함) | ❌ 명시 바인딩 필요 |
| `state:Apply(factory)` | factory 파라미터 주석 필요 | ❌ 명시 바인딩 필요 |
| `Effect(fn, state)` | 해당 없음(자유 함수) | 해당 없음(반환이 재귀 타입 아님) |
| `state:Observer(fn)` | 해당 없음(로컬 제네릭 없음) | 해당 없음(`EffectHandle` 반환) |
**`Effect`/`Observer`는 이 문제와 무관합니다** — 한때 0-Y가 "같은
lazy 핸들 계약을 공유하니 같이 걸린다"고 서술했으나 실측 결과 아니었음
(각각 자유 함수라서, 그리고 로컬 제네릭 반환이 없어서).
### 언제 풀리는가 — 지금 그대로 두면 자동으로 풀림
- **Luau RFC**: [`relax-recursive-type-restriction`](https://rfcs.luau.org/relax-recursive-type-restriction.html)
— 완화 근거로 "This pays for itself in the considerable gain in
expressivity gained for users of the type system"을 명시. RFC가
직접 드는 "지금은 거부되는" 예시가 `Promise<T>.andThen:
<U>(self: Promise<T>, callback: (T) -> Promise<U>) -> Promise<U>`로,
**우리 `Compute`와 글자 그대로 같은 모양**입니다.
- **완화 메커니즘은 순수 내부 변경**("타입 별칭을 진짜 type function처럼
취급해 lazy expansion") — **사용자 문법 변경 없음.** 즉 지금 우리가
쓰는 선언 그대로 두면, Luau 쪽이 고쳐지는 순간 **코드 변경 없이**
올바르게 풀립니다.
- **추적**: [`luau-lang/luau#2380`](https://github.com/luau-lang/luau/issues/2380)
("Allow recursive generic types to differ", 2026-08-13 기준 열려
있음). 이게 닫히면 이 절의 ①(명시 바인딩 강제)을 재검증하고,
불필요해지면 관례를 풀 것.
- **참고 — 옛 솔버는 이 패턴을 선언 시점에 거부**했습니다(`--solver=old`).
새 솔버는 선언을 받아주지만 위처럼 조용히 새는 중간 상태입니다.
즉 RFC의 완화가 **선언 검사까지는 들어왔고 인스턴스화까지는 아직**인
것으로 보입니다.
### 미리 대비해 둘 것 — 없음
RFC가 순수 내부 변경이고 우리 선언이 이미 그 대상 모양이므로,
"미래에 자연히 등록되도록 플레이스홀더를 심어두는" 종류의 작업은
**필요 없다는 게 실측 결론**입니다. 오히려 지금 뭔가 심어두는 게
0번 대전제 위반입니다.
---
## 2. `Modifier.Overridden`의 서브타입 합성은 정적 체크 포기
**근거: `luau-test/done/09-type-modifier-overridden-subtype.luau`**
`FrameModifier``GuiObjectModifier`의 서브타입이어야 자연스러운데,
필드 setter 메소드의 반환 타입이 각각 자기 자신이라 같은 이름 필드끼리
반환 타입이 갈려 구조적 서브타이핑이 깨집니다 — 실측으로 재현 확인.
**우리가 하는 것**: `Modifier.Overridden`의 시그니처를 `(...: any): any`류로
느슨하게 열어 정적 체크를 포기(fallback이 정상 작동함도 같이 확인됨).
상세는 `base/modifier-plan.md` 9-2번 절.
---
## 3. `AttributeKey<<T>>` 제네릭 키의 값 타입 narrowing은 안 됨
**근거: `luau-test/done/12-type-attribute-generic-key-narrowing.luau`**
`[AttributeKey<<T>> "name"] = value`에서 `T`가 이름별로 고정되지 않고
호출마다 독립 추론돼 narrowing이 전혀 강제되지 않음 — 실측으로 "안 됨"
확정.
**우리가 하는 것**: `base/attribute-plan.md`가 이미 예비해둔 fallback을
채택 — 정적 체크가 필요하면 `BooleanAttribute` 같은 **타입 패밀리**가
유일하게 믿을 수 있는 경로.
---
## 4. `Source(default)`/`Ref(default)`의 nilable 캐비엇은 타입으로 못 막음
**근거: `luau-test/done/14-type-nilable-default-overload.luau`**
`default` 생략이 `T`가 nilable일 때만 안전하다는 캐비엇을 함수
오버로드(교차 타입)로 막으려던 스케치는, 의도한 오용은 정확히 막지만
**정상 nilable 사용례까지 같이 막아** 채택 불가.
**우리가 하는 것**: 타입으로 강제하지 않고 문서 경고(UB)로 유지 —
`base/bind-system-plan.md`의 해당 절. 새 설계 결정이 필요한 항목은
아님(대안이 이미 존재).
---
## 5. `store.key` 레코드 필드 타이핑(`type function`)은 미검증
**근거: `luau-test/rewrite-required/16-type-store-key-typefunction.luau`**
`Store<T>``{[K]: Source<V>}` 합성을 Luau `type function`으로 하는
설계는 **설계 레벨로는 확정**(`pre-implementation-audit.md` 1-10)이지만,
스파이크가 `types.newfunction` 시그니처 불일치로 깨져 **실측 확인이 안
된 상태**입니다.
**우리가 하는 것**: 스파이크 재작성 후 재시도(에이전트 몫,
`luau-test/STATUS.md` 🟠). `type function`은 비교적 최근/진화 중인
기능이라 버전에 따라 API가 다를 수 있음.
---
## 6. 성립이 확인된 것 (안심해도 되는 것)
한계만 모아두면 "타입이 다 안 되는구나"로 오독되기 쉬워서 같이 적습니다.
아래는 **실측으로 통과 확인**된 것들이라 다시 의심하지 말 것:
- **`Source<T>`가 `State<T>`를 구조적으로 만족**(서브타입으로 그대로
넘길 수 있음) — `luau-test/review-required/08`. 단 **단방향 의존을
유지해야 함**(`State<T>`가 `Source`를 참조하면 안 됨 — 두 제네릭
별칭의 상호 재귀는 솔버가 취약한 패턴).
- **`PreRef<T>`가 `Ref<T>` 자리에 대입 가능** — `luau-test/13` A섹션.
- **Modifier의 제네릭 `__index` + `table.clone` 체이닝** — `luau-test/done/17`.
- **콜백 파라미터/본문의 타입 체크**(1번의 쪼개기 적용 시) — 진짜
살아있음.
---
## 7. 새 타입/API를 설계할 때 체크리스트
1. **자기 이름을 다른 타입 인자로 감싸 반환하는가?**(`Foo<T>` 안에서
`-> Foo<U>`) → 1번 한계에 걸림. 설계를 바꾸지 말고(0번 대전제),
명시 바인딩 관례를 문서에 같이 적을 것.
2. **로컬 제네릭을 가진 메소드가 재귀 타입의 필드인가?** → 1번의
"쪼개기"를 적용할 것(`XxxData<T>` / `Xxx<T>` 분리).
3. **제네릭 키로 값 타입을 좁히려 하는가?** → 3번, 안 됨. 타입
패밀리를 쓸 것.
4. **서브타입 관계인 두 타입을 합성하려 하는가?** → 2번, 메소드 반환
타입이 갈리면 깨짐.
5. **타입으로 오용을 막으려 하는가?** → 4번 사례처럼 정상 사용례까지
막는 경우가 흔함. 막기 전에 정상 사용례를 반드시 같이 테스트할 것.
6. **위 어디에도 안 걸리는데 안 되는 것 같다**`luau-test/`
스파이크를 추가하고 실측할 것. **추론만으로 "된다/안 된다"를
확정하지 말 것** — 이 문서의 항목 중 여러 개가 "된다고 믿었다가
실측에서 뒤집힌" 것들입니다.
> **실측 방법 주의**: `luau-analyze`가 진단 0건이어도 타입이 제대로
> 해소됐다는 뜻이 아닙니다(1번이 정확히 그 사례). **`luau-analyze
> --annotate`로 추론된 실제 타입을 눈으로 확인**하고, 가능하면
> "일부러 틀린 타입에 대입해서 진짜 에러가 나는지" 음성 대조군을
> 같이 둘 것.
---
## 8. 미해결 / 추적 중
- **에디터(`luau-lsp`)의 솔버 설정** — `luau-analyze` CLI는 새 솔버가
기본값이지만 `luau-lsp`**옛 솔버가 기본값**(`LuauSolverV2=false`)이라
같은 코드에 다른 진단이 나옵니다. 새 솔버로 맞추려면
`"luau-lsp.fflags.enableNewSolver": true`. **M0 실착수 때 실제 에디터
환경에서 확정할 것** — 옛 솔버는 1번 패턴을 아예 거부하므로 사실상
새 솔버 외에 선택지가 없어 보이지만, 실환경에서 확인 필요.
- **5번(`store.key` type function)** — 스파이크 재작성 후 실측.
- **`luau-lang/luau#2380`** — 닫히면 1번 관례 재검증.