# `quad-types` — 구현 없는 `Quad` 타입 계약 + 컴파일 타임 버전 체크 **상태**: base — 2026-08-19 세션에 신설·구현·검증까지 완료. 워크스페이스 세 번째 멤버 `quad-types`의 존재 이유, `AddPlugin`/`CheckedQuad`의 정확한 사용법, 그 배선에서 실제로 깨졌던 Luau 함정들을 정리. ## 왜 필요한가 — dev-dependency로는 못 푸는 문제 `quad-roblox`는 `QuadRoblox(Quad): QuadRoblox`처럼 quad-base 인스턴스를 **런타임에 함수 인자로 주입**받는다(`base/module-lifecycle-plan.md` "Bind는 누가, 어떻게 구현하는가" 절이 확정해둔 팩토리 패턴 — quad-roblox 자신은 quad-base를 `require`할 필요가 없어 보인다). **그런데 타입 주석 하나 때문에 얘기가 달라진다.** `QuadRoblox`의 시그니처가 `Quad` 타입을 참조하려면 그 타입이 정의된 모듈을 `require`해야 하고, **이 require는 "타입만 쓰려는 목적이어도 런타임에 실제로 실행된다"** (실측 확인, 2026-08-19 — `require`가 반환하는 모듈에서 export 타입만 꺼내 써도 그 `require` 문 자체는 평범한 런타임 호출이라, 대상이 없으면 그 자리에서 크래시함). 그래서: - `quad_base`를 **일반 의존성**으로 두면: 소비자가 `quad-roblox`를 설치할 때마다 무거운 quad-base 전체가 통째로 딸려온다(quad-base를 이미 따로 설치해서 `QuadRoblox(Quad)`에 넘기는 상황이면 완전히 중복). - `quad_base`를 **dev-dependency**로 두면: 로컬 개발 중엔 문제없지만, `quad-roblox`가 게시된 뒤 **소비자 환경엔 dev-dependency가 전파되지 않아** 그 타입-전용 require가 못 찾고 그 자리에서 런타임 크래시난다. **해법**: `Quad`의 타입 계약만 담은, 런타임 구현이 사실상 없는 세 번째 워크스페이스 패키지 `quad-types`를 두고, `quad-roblox`는 이것만 **일반 의존성**으로 둔다 — 항상 안전하게 실 의존성으로 넣을 수 있을 만큼 작고, quad-base 전체를 안 끌고 온다. ## `quad-base` 안에 폴더로 두면 안 되는가 — 안 됨 pesde의 워크스페이스 의존성은 **패키지 단위**로만 걸린다 (`{ workspace = "scope/name" }`) — 서브폴더 단위 의존 문법이 없다. `quad-base/types/`처럼 폴더로 만들어도 `quad-roblox`가 그걸 가져오려면 결국 `quad_base` 패키지 전체를 의존성으로 선언해야 하고, 실제로 링크되는 것도 quad-base 전체 소스 트리다(어느 파일을 실제로 require하는지와 무관). 그래서 **반드시 별도 pesde 패키지**(`workspace_members`의 새 멤버)여야 "가벼운 타입만" 효과가 실제로 생긴다. ## 구조 ``` quad-types/ ├── pesde.toml # name = "qwreey/quad_types" └── src/init.luau # export type Quad, type function CheckVersion, export type CheckedQuad ``` - `quad-base`는 `quad_types`에 workspace 의존 — **자기 `Quad` 타입을 따로 선언하지 않고 `type Quad = QuadTypes.Quad`로 그대로 가져다 씀** (한 곳에만 진실이 있게, 구현이 계약과 어긋나면 구조적 타입에러로 자연히 드러남). 실제로 `quad-base/src/init.luau`에 반영됨. - `quad-roblox`도 `quad_types`에 workspace 의존(quad-base 아님). - **[참고, 2026-08-19 사용자 판단] 모든 백엔드/플러그인 패키지가 이 패턴을 따를 필요는 없다** — 예: 가상의 `quad-spring`/`quad-spring-roblox` 쌍은 타입 분리 없이 `quad-spring-roblox`가 `quad-spring`을 평범하게 일반 의존성으로 둬도 된다("주입만 하면 Spring이 같이 따라오도록"). `quad-types` 분리는 **quad-base처럼 사실상 모든 패키지가 의존하는 핵심 계약**일 때만 값어치가 있다. ## `Quad` 타입 — 확정된 표면 ```lua export type Quad = { Version: "0.0.0", -- quad-base/pesde.toml의 version과 항상 맞출 것 debug: boolean, New: () -> Quad, RunInit: (self: Quad, initFn: (Quad) -> any) -> (), AddPlugin: (self: Self, pluginFn: (Self) -> P) -> Self & P, } ``` `Version`은 리터럴(singleton) 타입 — `string`이 아니라 정확히 `"0.0.0"`. 이 리터럴이 아래 `CheckVersion`의 판정 근거이자, 그 자체로도 평범한 구조적 타이핑만으로 이미 어느 정도 버전 불일치를 잡아준다(다른 리터럴 `"0.1.0"`은 `"0.0.0"`과 구조적으로 호환 안 됨) — `CheckVersion`이 주는 추가 가치는 **감지 자체**가 아니라 사람이 읽을 수 있는 진단 메시지다 (아래 절). ## `AddPlugin` — 실측 검증된 플러그인 체이닝 ```lua AddPlugin: (self: Self, pluginFn: (Self) -> P) -> Self & P ``` `Self`를 고정된 `Quad`가 아니라 **제네릭**으로 둬야 체이닝이 누적된다 (고정하면 두 번째 `AddPlugin` 호출이 첫 번째 확장을 잃어버림). 실측 확인(2026-08-19): - `quad:AddPlugin(springFn)`(→ `Quad & SpringPlugin`)`:AddPlugin(otherFn)` 체이닝 결과가 정확히 `Quad & SpringPlugin & OtherPlugin`로 누적됨. - 플러그인 추가 전 그 메소드에 접근하면 정확히 `Key 'X' not found`로 거부됨(음성 대조군). **런타임 구현**(quad-base, 실제 반영됨): `pluginFn(self)`를 호출해 얻은 확장 테이블의 필드를 `self`에 **직접 mutate**하고 `self` 그대로 반환 — 새 테이블을 만들지 않는다. 이유: `RunInit`의 멱등 추적이 `module` identity에 의존하므로(`base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절), `AddPlugin`이 새 테이블을 반환하면 그 추적이 끊긴다. ## `CheckedQuad` — 버전 불일치를 컴파일 타임에 사람이 읽을 메시지로 **왜 필요한가**: `quad-roblox`가 `quad_base`를 pesde `[dependencies]`로 선언하지 않고 런타임 주입으로만 받게 되면서, **pesde 자신의 semver 충돌 방지 장치가 이 관계엔 전혀 안 걸린다** — 선언된 의존성이 아니라 그냥 함수 인자라서. `CheckedQuad`가 그 빈자리를 메꾸는 컴파일 타임 대체 안전장치다(사용자 판단: "런타임 에러까지 내려면 quad-roblox 소스에 버전을 하드코딩해야 하는데 그건 과함 — 타입 에러만 내는 걸로 충분"). ```lua export type CheckedQuad = T & { __versionCheck: CheckVersion } ``` **사용법**: ```lua local function CheckQuad(quad: T): QuadTypes.CheckedQuad return quad :: any end local checked = CheckQuad(injectedQuad) local _ = checked.__versionCheck -- ⚠️ 필수 — 아래 "함정 2" 참고 local quad = checked:AddPlugin(installRobloxBackend) -- 이후 정상적으로 체이닝 ``` ### 실측으로 깨진 시도들 (전부 순서대로 실제로 시도하고 버림) **함정 1 — `error()`로 에러 내면 안 됨.** `type function` 안에서 `error()`를 부르면 "이 type function 자체가 실행에 실패함"으로 판정돼 버려져서 원하는 메시지가 안 뜬다. **`print("메시지")` + `return types.never` 조합만 호출부에 정확히 `TypeError: <메시지>`로 뜬다** (실측 확인 — 3줄짜리 최소 재현으로 검증). **함정 2 — 함수 본문 안의 로컬 타입 별칭으론 절대 평가 안 됨.** ```lua -- ❌ 이렇게 하면 아무 진단도 안 뜬다(제네릭 인스턴스화 시 재평가 안 됨) local function QuadRoblox(quad: T): T type _Check = CheckVersion return quad end ``` 체크는 **리턴 타입/필드 타입처럼 호출부마다 실제로 해석되는 자리**에 박아 넣어야 한다. 이게 `CheckedQuad`가 함수 파라미터/반환 타입 표현식 안에 직접 나타나야 하는 이유고, `__versionCheck` 필드도 **실제로 참조해야만** 평가된다(lazy) — 위 사용법 예제의 `local _ = checked.__versionCheck` 줄이 빠지면 검사가 조용히 스킵된다. **함정 3 — [가장 중요, 가장 늦게 발견] 값이 한 번이라도 `type function`을 거치면 이후 제네릭 self 메소드 체이닝이 조용히 깨진다.** 처음엔 `CheckVersion`가 성공 시 `T`를 그대로 패스스루(`return t`)하는 버전으로 짰다 — 단독으로는 완벽히 통과했다(리턴 타입 표현식에 직접 써서 즉시 평가되고, `AddPlugin`도 안 뭉개지는 것처럼 보였다). 그런데 **`CheckVersion`의 결과를 다른 타입과 `&`로 합친 뒤 `AddPlugin`을 호출하는 조합**에서 `Expected this to be exactly 'P & Self', but got 'P & Self'`처럼 **앞뒤가 똑같은, 의미 없는 진단**이 뜨며 깨졌다. 더 좁혀보니, 심지어 **`&`로 안 합쳐도, `CheckVersion`를 거친 값에 `AddPlugin`을 부르기만 해도 똑같이 깨졌다** — 재구성 비용(값을 `types.newtable()`로 다시 조립하는 것) 문제가 아니라, **"이 타입이 `type function`을 거쳤다는 이력 자체"**가 이후 제네릭 self 추론을 방해하는 것으로 보인다. 그래서 최종 설계는 **`CheckVersion`이 `T`를 전혀 참조/반환하지 않고**(성공 시 트리비얼한 `types.singleton(true)` 하나만), 검증 결과를 원본과 절대 안 섞이는 **별도 필드** (`__versionCheck`)로 완전히 격리한다 — `T` 자신은 `type function`을 한 번도 거치지 않은 "순수한" 타입으로 계속 흘러가므로, 그 뒤 `AddPlugin` 체이닝이 몇 번이 되든 전혀 안 깨진다(실측 확인). **교훈**: `type function`의 부작용은 "재구성이 원본을 뭉갠다"는 상상보다 넓다 — **패스스루도 이력만으로 오염된다.** 검증/변형용 `type function`을 설계할 때는 원본 타입을 **절대 반환하지 말고**, 트리비얼한 마커만 반환해서 완전히 별도 필드로 격리할 것 — `typing-limits.md`의 "새 타입/API를 설계할 때 체크리스트"에 추가할 후보. ## 실측 근거 `.claude/luau-test/done/23-type-quadtypes-checkversion-addplugin.luau` — 실제 `quad-types`/`quad-base`를 `require`해서 위 사용법 그대로 재현: 양성 경로(버전 일치 + `AddPlugin` 2회 체이닝 + 이전 확장 필드 유지) 전부 클린, 음성 경로(버전 불일치)는 정확히 그 줄에서 `TypeError: quad-base version mismatch: ...` 하나만. `quad-base/test/smoke.plugin.luau` — 실제 런타임 `AddPlugin` 구현(mutate + identity 보존 + 체이닝)을 실행 레벨로 검증. ## 남은 것 - `quad-roblox`가 실제로 `CheckedQuad`를 쓰는 진입점(`QuadRoblox` 등) 구현은 M5 — 지금은 quad-roblox/src가 비어 있어 이 문서의 사용법 예제가 실제 위치는 아직 없음. - `_initializedBy`(별도 문자열 마커, backend 유일 슬롯 가드)와의 관계는 `base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절 참고 — `CheckedQuad`는 **버전** 호환성만 보고, **누가 이미 backend를 설치했는지**는 별개 문제로 계속 `_initializedBy`가 담당한다.