콘텐츠로 이동

Store

Store는 이름 붙은 Source들의 가방입니다. 값을 담는 새 반응형 노드가 아니라, 이미 만든 Source들을 한 레코드로 묶어 타입이 붙은 점 접근(store.hp)을 주는 것이 전부입니다.

이 페이지의 심볼: q.Store(defaults) · store.key · store:Of<<U>>(name) · store:Names() · 예약 키

-- 01장의 설정 모듈: quad_base에 quad_roblox를 설치하고 타입을 다시 내보낸다(시작하기 01 참고)
local q = require("@game/ReplicatedStorage/Client/UI/Quad")

시그니처

Store: (() -> Store<{}>) & (<T>(defaults: T) -> Store<T>)
export type Store<T> = T & {
Of: <U>(self: any, name: string) -> Source<U>,
Names: (self: any) -> { string },
__reservedCheck: CheckReservedKeys<keyof<T>>,
}

인자

이름 타입 설명
defaults T 초기 필드. 넘긴 레코드 타입이 그대로 T가 됩니다. 런타임 계약은 메타테이블 없는 평범한 테이블이고 값은 **전부 Source**여야 한다는 것입니다 — 생략하면 빈 Store입니다.

반환 — Store<T>. T는 넘긴 레코드 타입 그대로입니다(타입 함수 없이 평범한 레코드).

동작

  • 초기화는 명시적입니다. 넘긴 테이블의 얕은 복사가 store가 되고(table.clone — 넘긴 테이블 자체는 아니라 그 뒤 거기에 넣은 키는 store에 안 나타나고, 얼린 테이블을 넘겨도 store는 얼지 않습니다), 메소드는 메타테이블 쪽에 있습니다. 그래서 store.hp는 넣은 그 Source 객체 자신이고, :Names()에 메소드 이름이 섞이지 않습니다.

  • 값 검증은 화이트리스트입니다 — Source가 아닌 값은 그 자리에서 거부합니다. 통과시켰다면 첫 :Get()에서야 죽었을 것이기 때문입니다.

  • 검증 에러 문구:

    상황 문구
    값이 Source가 아님 Quad0198 Store: default for "{tostring(name)}" is not a Source (got {typeof(value)})
    테이블이 아님 Quad0195 Store: defaults must be a table of Sources (got {typeof(defaults)})
    메타테이블이 달림(Source 하나를 그대로 넘긴 경우 등) Quad0196 Store: defaults must be a plain table without a metatable (a bare Source instead of { name = Source }?)
    브랜드 값(AttrKey·Mapper 디스크립터 등) Quad0197 Store: defaults must be a plain { name = Source } table (got an AttrKey/Mapper descriptor)
    키가 문자열이 아니거나 빈 문자열 Quad0193 Store: {what} must be a non-empty string (got {if name == "" then '""' else typeof(name)}) ({what}은 생성자에서 key, :Of에서 Of name)
    예약된 이름 Quad0194 Store: "{name}" is a reserved store key

    틀린 항목이 여럿이어도 에러는 하나만 납니다. 어느 항목의 어느 문구가 나는지는 테이블 순회(해시) 순서가 정해 정해져 있지 않습니다 — 같은 내용의 테이블로 여러 번 만들어도 매번 다를 수 있습니다([2026-09-27 mock 실측]). 하나를 고쳤더니 다른 문구가 새로 나면 남은 항목도 같이 확인하세요.

  • Source의 값 제약이 그대로 따라옵니다 — Modifier는 담을 수 없습니다. 이 검사는 Source 생성자가 하므로 :Of로 만든 필드에도 적용됩니다.

  • print(store)는 정렬된 키 목록으로 Store{hp, mp} 모양입니다.

  • table.clone(store)로 만든 사본은 Store가 아닙니다 — q.isStore가 거짓이고, q.Attr에 넘기면 Quad0010으로 거부됩니다([2026-09-27 mock 실측]). 같은 모양의 Store가 하나 더 필요하면 q.Store로 새로 만드세요.

예제

local store = q.Store({
hp = q.Source(100),
name = q.Source("player"),
})
print(store.hp:Get()) --> 100
store.hp:Set(80)

관련 — How-To 02 폼 검증 · 02-source


동작

  • 선언한 키는 평범한 레코드 필드입니다 — 넣은 Source가 그 자리에 그대로 있고, 타입도 넘긴 레코드 타입 그대로 붙습니다. store.hp는 Source<number>이지 래퍼가 아닙니다.
  • 선언하지 않은 키를 점으로 읽으면 nil입니다. 점 접근에는 지연 생성이 없습니다 — 없는 이름을 그 자리에서 만들어주는 문은 :Of 하나뿐입니다. 예외는 __로 시작하는 예약 이름입니다 — store.__index·store.__tostring처럼 점으로 읽으면 내부 값(공유 메소드 테이블, 함수)이 나오지만, 사용 범위 밖이니 읽거나 고치는 코드에 기대지 마세요.
local store = q.Store({ hp = q.Source(100) })
print(store.hp:Get()) --> 100
print((store :: any).mp) --> nil (선언 안 한 이름 — 만들어지지 않는다)

시그니처

Of: <U>(self: any, name: string) -> Source<U>

인자

이름 타입 설명
name string 필드 이름. 빈 문자열이 아닌 문자열이어야 하고, 예약 이름이면 안 됩니다.

반환 — 그 이름의 Source<U>. 없으면 그 자리에서 만들어 저장하고 돌려줍니다.

동작

  • 계산된 이름을 쓰는 유일한 문이자 지연 생성이 남아 있는 유일한 자리입니다. 타입이 이름을 알 수 없으므로 결과 타입은 호출자가 타입 인자로 직접 줍니다 — 그만큼 타입 안전은 여기서 포기하는 것입니다.
  • 새로 만들어진 Source의 값은 **nil**입니다. store:Of<<number>>("mp"):Get()은 타입이 number여도 첫 호출에선 nil입니다 — 필요하면 바로 :Set하세요.
  • 같은 이름으로 다시 부르면 같은 객체가 옵니다.
  • 타입 인자 U는 검사되지 않는 캐스트입니다 — 이미 있는 필드를 :Of로 집어도 그 필드의 실제 타입과 대조하지 않고 Source<U>로 내줍니다. Peek<<T>>/As<<T>>와 같은 부류이니, 이름과 타입이 맞는지는 호출자가 책임집니다.
  • 이름 검증: Store: {what} must be a non-empty string(여기서 {what}은 Of name) / Store: "{name}" is a reserved store key

예제

local store = q.Store({ hp = q.Source(100) })
local mp = store:Of<<number>>("mp") -- 결과 타입은 호출자가 직접 준다
mp:Set(50) -- 갓 만들어진 Source의 값은 nil이므로 먼저 채운다
print(store:Of<<number>>("mp") == mp) --> true (같은 객체)

시그니처

Names: (self: any) -> { string }

반환 — 지금 이 store에 있는 필드 이름들.

동작

  • 생성자에 선언한 키와 :Of가 만든 키가 둘 다 들어갑니다.
  • 메소드(Of/Names)는 메타테이블 쪽에 있어 세지 않고, 타입에만 있는 팬텀 필드도 들어가지 않습니다.
  • 순서는 보장되지 않습니다 — 필요하면 직접 정렬하세요.

예제

local store = q.Store({ hp = q.Source(100) })
store:Of("mp")
local names = store:Names()
table.sort(names)
print(table.concat(names, ",")) --> hp,mp

Of, Names, 그리고 __(밑줄 둘)로 시작하는 모든 이름은 store 필드 이름으로 쓰지 마세요 — 생성자와 :Of가 거부합니다(그 두 문 밖에서 store.__x = v처럼 직접 대입하는 것까지 막지는 않습니다 — 그 결과도 보장하지 않습니다: store.x = 5처럼 Source가 아닌 값을 직접 넣으면 store:Of("x")가 그 값을 그대로 돌려주고, store.Of = …는 메소드를 가려 store:Of(...)가 죽고, store.hp = nil로 지운 뒤 :Of("hp")는 새 Source를 만들어 옛 Source를 구독하던 Compute와 조용히 갈라집니다. strict가 유일하게 통과시키는 직접 대입인 store.hp = q.Source(7)(같은 타입의 다른 Source로 교체)도 결과는 nil 삭제와 같아서, 옛 Source를 구독하던 Compute와 먼저 만든 q.Attr(store)는 옛 Source에 조용히 남습니다([2026-09-27 실측]). tostring(store)만은 어떤 키가 섞여도 던지지 않습니다). 앞의 둘은 메소드 이름이고, __ 접두는 타입 검사용 팬텀 필드(__reservedCheck 등) 자리입니다.

  • Store의 메소드는 Of/Names에서 늘어나지 않습니다. 사용자 필드와 메소드가 같은 테이블 이름 공간에 있어, 메소드를 하나 더하면 그 이름을 필드로 쓰던 코드가 깨지기 때문입니다. Store에 기능이 더 필요해지면 store를 인자로 받는 함수로 나옵니다. 그래서 Of/Names와 __ 접두만 피하면 앞으로도 어떤 필드 이름이든 안전합니다.

  • 런타임: 생성자와 :Of 둘 다 같은 문으로 막습니다 — Store: "{name}" is a reserved store key

  • 타입 검사 시점: Store<T>에 얹힌 타입 함수가 주석 자리에서 먼저 경고를 냅니다. 타입 함수는 에러를 던질 수 없어 출력으로만 알립니다 — quad.Store: "{v}" is a reserved key

관련 — 02-source · 03-state