quad/.claude/base/quad-types-plan.md
qwreey-agent-selene 297c4d459c
design: quad-types 패키지 신설 — AddPlugin<Self,P> + CheckedQuad<T> 실측 설계
quad-roblox가 quad-base를 런타임 주입(QuadRoblox(Quad): QuadRoblox)으로만
받으면 pesde 의존 선언이 필요 없어 보이지만, 타입 참조용 require도
런타임에 실제 실행됨을 실측 확인 — dev-dependency로 두면 게시 후
소비자 환경에서 크래시함. 해법으로 구현 없는 타입 계약 전용 워크스페이스
패키지 quad-types 신설, quad-base/quad-roblox 모두 이것만 의존하도록
전환.

AddPlugin<Self,P>(self:Self,fn:(Self)->P):Self&P — 제네릭 self로 둬야
체이닝이 누적됨을 실측 확인(고정하면 이전 확장을 잃음), quad-base에
실제 mutate 기반 구현 반영.

CheckedQuad<T> 버전 체크는 배선하며 세 번 깨짐 — error() 대신
print+types.never, 함수 본문 로컬 별칭 대신 리턴 타입 표현식에 직접,
그리고 가장 중요하게 type function을 한 번이라도 거친 값(패스스루
포함)은 이후 AddPlugin 같은 제네릭 self 체이닝이 조용히 깨진다는 새
Luau 함정 발견 — typing-limits.md §6으로 승격. 최종 설계(검증 결과를
원본과 격리된 가상 필드로)만 AddPlugin과 완전히 호환.

부수로 quad-base 자신도 quad-types workspace 의존 때문에 CLI symlink
함정(지난 세션 발견)에 걸림 — 로컬 테스트용 symlink 실체화로 임시 우회.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-19 17:09:16 +09:00

11 KiB

quad-types — 구현 없는 Quad 타입 계약 + 컴파일 타임 버전 체크

상태: base — 2026-08-19 세션에 신설·구현·검증까지 완료. 워크스페이스 세 번째 멤버 quad-types의 존재 이유, AddPlugin/CheckedQuad의 정확한 사용법, 그 배선에서 실제로 깨졌던 Luau 함정들을 정리.

왜 필요한가 — dev-dependency로는 못 푸는 문제

quad-robloxQuadRoblox(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_basedev-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<T>
  • quad-basequad_types에 workspace 의존 — 자기 Quad 타입을 따로 선언하지 않고 type Quad = QuadTypes.Quad로 그대로 가져다 씀 (한 곳에만 진실이 있게, 구현이 계약과 어긋나면 구조적 타입에러로 자연히 드러남). 실제로 quad-base/src/init.luau에 반영됨.
  • quad-robloxquad_types에 workspace 의존(quad-base 아님).
  • [참고, 2026-08-19 사용자 판단] 모든 백엔드/플러그인 패키지가 이 패턴을 따를 필요는 없다 — 예: 가상의 quad-spring/quad-spring-roblox 쌍은 타입 분리 없이 quad-spring-robloxquad-spring을 평범하게 일반 의존성으로 둬도 된다("주입만 하면 Spring이 같이 따라오도록"). quad-types 분리는 quad-base처럼 사실상 모든 패키지가 의존하는 핵심 계약일 때만 값어치가 있다.

Quad 타입 — 확정된 표면

export type Quad = {
	Version: "0.0.0", -- quad-base/pesde.toml의 version과 항상 맞출 것
	debug: boolean,
	New: () -> Quad,
	RunInit: (self: Quad, initFn: (Quad) -> any) -> (),
	AddPlugin: <Self, P>(self: Self, pluginFn: (Self) -> P) -> Self & P,
}

Version은 리터럴(singleton) 타입 — string이 아니라 정확히 "0.0.0". 이 리터럴이 아래 CheckVersion의 판정 근거이자, 그 자체로도 평범한 구조적 타이핑만으로 이미 어느 정도 버전 불일치를 잡아준다(다른 리터럴 "0.1.0""0.0.0"과 구조적으로 호환 안 됨) — CheckVersion이 주는 추가 가치는 감지 자체가 아니라 사람이 읽을 수 있는 진단 메시지다 (아래 절).

AddPlugin<Self, P> — 실측 검증된 플러그인 체이닝

AddPlugin: <Self, P>(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<T> — 버전 불일치를 컴파일 타임에 사람이 읽을 메시지로

왜 필요한가: quad-robloxquad_base를 pesde [dependencies]로 선언하지 않고 런타임 주입으로만 받게 되면서, pesde 자신의 semver 충돌 방지 장치가 이 관계엔 전혀 안 걸린다 — 선언된 의존성이 아니라 그냥 함수 인자라서. CheckedQuad<T>가 그 빈자리를 메꾸는 컴파일 타임 대체 안전장치다(사용자 판단: "런타임 에러까지 내려면 quad-roblox 소스에 버전을 하드코딩해야 하는데 그건 과함 — 타입 에러만 내는 걸로 충분").

export type CheckedQuad<T> = T & { __versionCheck: CheckVersion<T> }

사용법:

local function CheckQuad<T>(quad: T): QuadTypes.CheckedQuad<T>
	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 — 함수 본문 안의 로컬 타입 별칭으론 절대 평가 안 됨.

-- ❌ 이렇게 하면 아무 진단도 안 뜬다(제네릭 인스턴스화 시 재평가 안 됨)
local function QuadRoblox<T>(quad: T): T
	type _Check = CheckVersion<T>
	return quad
end

체크는 리턴 타입/필드 타입처럼 호출부마다 실제로 해석되는 자리에 박아 넣어야 한다. 이게 CheckedQuad<T>가 함수 파라미터/반환 타입 표현식 안에 직접 나타나야 하는 이유고, __versionCheck 필드도 실제로 참조해야만 평가된다(lazy) — 위 사용법 예제의 local _ = checked.__versionCheck 줄이 빠지면 검사가 조용히 스킵된다.

함정 3 — [가장 중요, 가장 늦게 발견] 값이 한 번이라도 type function을 거치면 이후 제네릭 self 메소드 체이닝이 조용히 깨진다. 처음엔 CheckVersion<T>가 성공 시 T를 그대로 패스스루(return t)하는 버전으로 짰다 — 단독으로는 완벽히 통과했다(리턴 타입 표현식에 직접 써서 즉시 평가되고, AddPlugin도 안 뭉개지는 것처럼 보였다). 그런데 CheckVersion<T>의 결과를 다른 타입과 &로 합친 뒤 AddPlugin을 호출하는 조합에서 Expected this to be exactly 'P & Self', but got 'P & Self'처럼 앞뒤가 똑같은, 의미 없는 진단이 뜨며 깨졌다. 더 좁혀보니, 심지어 &로 안 합쳐도, CheckVersion<T>를 거친 값에 AddPlugin을 부르기만 해도 똑같이 깨졌다 — 재구성 비용(값을 types.newtable()로 다시 조립하는 것) 문제가 아니라, **"이 타입이 type function을 거쳤다는 이력 자체"**가 이후 제네릭 self 추론을 방해하는 것으로 보인다. 그래서 최종 설계는 CheckVersionT를 전혀 참조/반환하지 않고(성공 시 트리비얼한 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-baserequire해서 위 사용법 그대로 재현: 양성 경로(버전 일치 + AddPlugin 2회 체이닝 + 이전 확장 필드 유지) 전부 클린, 음성 경로(버전 불일치)는 정확히 그 줄에서 TypeError: quad-base version mismatch: ... 하나만.

quad-base/test/smoke.plugin.luau — 실제 런타임 AddPlugin 구현(mutate

  • identity 보존 + 체이닝)을 실행 레벨로 검증.

남은 것

  • quad-roblox가 실제로 CheckedQuad<T>를 쓰는 진입점(QuadRoblox 등) 구현은 M5 — 지금은 quad-roblox/src가 비어 있어 이 문서의 사용법 예제가 실제 위치는 아직 없음.
  • _initializedBy(별도 문자열 마커, backend 유일 슬롯 가드)와의 관계는 base/module-lifecycle-plan.md의 "New()의 내부 구성" 절 참고 — CheckedQuad버전 호환성만 보고, 누가 이미 backend를 설치했는지는 별개 문제로 계속 _initializedBy가 담당한다.