Skip to content

Modifier

This content is not available in your language yet.

Modifier는 불변 프로퍼티 가방입니다. “이 프로퍼티들을 이렇게 설정한다”를 값 하나로 만들어 들고 다니다가, 인스턴스 props의 숫자 키 자리에 놓으면 그 필드들이 문자 키로 펼쳐집니다. 테마·변형·조건부 스타일을 컴포넌트 경계 너머로 넘기는 통로입니다.

모든 연산이 새 값을 돌려줍니다. setter 하나를 부를 때마다 얕은 clone이 하나 생기고 원본은 그대로 남습니다(값 자체도 table.freeze돼 있습니다). 그래서 하나의 기본 Modifier에서 갈라져 나온 형제 가지들이 서로를 오염시킬 수 없습니다.

q.Raw.Modifier(...)는 무타입 Modifier를 만듭니다. quad-roblox를 설치하면 클래스 태그가 붙은 타입드 생성자 q.Modifier "Class" (...)가 같이 생깁니다. 런타임은 q.Raw.Modifier.TypedFactory("<Class>")가 돌려준 생성자 그대로이고, 필드 setter 타입은 타입 함수가 그 클래스 타입에서 계산합니다(roblox/03 — Modifier와 Spread). 3.3까지는 이 무타입 생성자의 이름이 q.Modifier였습니다.

Roblox 요소에는 q.Modifier "Class"를 쓰세요. 무타입 Modifier는 필드가 하나도 타입 검사되지 않아, 3.4.0부터 quad-roblox의 타입드 요소(q.Elem "Frame" {…}) 배열부는 이것을 받지 않습니다(런타임은 그대로 동작합니다 — 막는 것은 타입입니다). 이 페이지의 무타입 예제는 base 층의 동작 설명이고, 요소에 놓는 예제는 타입드 생성자로 적었습니다.

이 페이지의 심볼: q.Raw.Modifier · mod:<Field> · mod:Peek · mod:Apply · mod:Overridden · mod:As(name) · mod:As<Class>() · q.Raw.Modifier.Overridden · q.Raw.Modifier.TypedFactory · q.Raw.Modifier.DefineSubtype

이 페이지의 모든 예제는 아래 프롤로그를 전제합니다.

-- 01장의 설정 모듈: quad_base에 quad_roblox를 설치하고 타입을 다시 내보낸다(시작하기 01 참고)
local q = require("@game/ReplicatedStorage/Client/UI/Quad")
local QuadTypes = require("@game/ReplicatedStorage/roblox_packages/quad_types") -- 설정 모듈이 다시 내보내지 않는 타입(`QuadTypes.ModifierFieldOut<T>` 등)
type Modifier = {
read __quadModifier: true, -- 무타입 Modifier의 클래스 태그. 타입드면 클래스 이름 문자열
Peek: <T>(self: Modifier, key: string) -> ModifierFieldOut<T>?,
Apply: <U>(self: any, factory: (any) -> U) -> U,
Overridden: (self: Modifier, ...any) -> any,
As: <T>(self: Modifier, name: string?) -> T,
}
-- 필드에 "저장된 그대로"의 값: 리터럴 | State 핸들 자체 | None(명시 해제)
type ModifierFieldOut<T> = T | State<T> | None

Peek/As는 결과 타입을 호출자가 명시하는 자리이고(mod:Peek<<UDim2>>("Size")), Apply의 self와 factory 인자가 any인 것은 children 유니언의 클래스 검사를 살리기 위한 의도된 절충입니다.

Peek<<T>>의 T는 어디까지인가 — 층 나눔. ModifierFieldOut<T>가 감싸는 것은 quad-base가 소유한 층뿐입니다 — State<T>, None, 그리고 반환의 ?(값이 없음). 백엔드가 더하는 값 층(Roblox의 Tween<T> 같은 것)은 quad-base가 알 수 없으므로 T 안에 호출자가 넣습니다. 백엔드마다 어떤 층이 있는지는 그 백엔드의 레퍼런스가 적습니다(quad-roblox: roblox/03). 백엔드의 타입드 Modifier(quad-roblox의 q.Modifier "Frame")의 Peek는 그 층을 이미 채워 두므로, 호출자가 층을 챙겨야 하는 것은 **무타입 q.Raw.Modifier()**를 쓸 때뿐입니다.

Modifier는 props의 숫자 키 항목입니다. 문자 키의 값 자리에 두면 합칠 대상이 없으므로 그 자리에서 거부됩니다.

  • Quad0068 Modifier: a Modifier cannot be a value of key "{tostring(k)}" - place it in the array part

펼치기 규칙은 셋입니다.

  1. 인라인 문자 키가 항상 이깁니다. props에 직접 적은 Size = …는 어떤 Modifier의 Size보다 우선합니다(직접 적은 None도 마찬가지).
  2. 숫자 키 자리의 뒤쪽 Modifier가 앞쪽을 이깁니다.
  3. 소비된 숫자 키 자리는 내부 센티널로 채워져 배열에 구멍이 생기지 않습니다.
local base = q.Modifier "Frame" { BackgroundTransparency = 1 }
local accent = q.Modifier "Frame" { BackgroundTransparency = 0.5 }
local box = q.Elem "Frame" { base, accent, Name = "Box" } -- accent가 이겨 0.5

시그니처

Modifier: setmetatable<{
Overridden: (...any) -> any,
TypedFactory: <T>(name: string) -> (...(Modifier | { [string]: any })) -> T,
DefineSubtype: (parent: string, subtype: string) -> (),
}, { __call: (self: any, ...(Modifier | { [string]: any })) -> Modifier }>

즉 호출하면 생성, 필드로 접근하면 합성 도구인 콜러블 네임스페이스입니다.

인자

이름 타입 설명
... Modifier 또는 { [string]: any } Modifier이거나 평범한 필드 테이블. 뒤 인자가 필드 단위로 덮어씁니다

반환 — 새 무타입 Modifier(태그 true).

동작

  • 인자가 없으면 빈 Modifier입니다 — q.Raw.Modifier()와 q.Raw.Modifier({})는 같습니다.
  • 필드 테이블은 메타테이블 없는 평범한 테이블이어야 합니다. Source/Ref/Tag 같은 quad 값을 넘기면 그 내부 필드가 합쳐지는 대신 거부됩니다.
  • 초기 필드 테이블의 값으로 함수는 못 씁니다. setter 자리에서 함수는 변환 함수라 의미가 갈리기 때문입니다 — 변환이 필요하면 mod:Field(fn)을 쓰세요.
  • 핸들러 계층 값(Ref/Observer/Effect/Slot/Modifier/Spread)은 필드가 될 수 없습니다. State/Source는 통과합니다.

에러

  • Quad0065 Modifier: argument #{i} must be a Modifier or a plain field table (got {typeof(arg)})
  • Quad0059 Modifier: initial-field table keys must be non-empty field names (got {if k == "" then '""' else typeof(k)} at argument #{i})
  • Quad0060 Modifier: field "{k}" collides with a reserved Modifier method
  • Quad0061 Modifier: field "{k}" matches the reserved cast prefix As<Class> - casts are methods, not fields
  • Quad0062 Modifier: field "{k}" starts with "__" - metamethod-looking names are never properties
  • Quad0063 Modifier: field "{k}" in an initial-field table cannot be a function - a transform goes through mod:{k}(fn); an event handler does not belong in a Modifier (put it as an inline key on the element, or wrap it in a State)
  • Quad0057 Modifier: field "{k}" cannot hold a handler-layer value (Ref/Observer/Effect/Slot/Modifier/Spread/mapper descriptor)

예제

local card = q.Modifier "Frame" {
BackgroundTransparency = 0.2,
BorderSizePixel = 0,
}
local wide = q.Modifier "Frame" (card, { Size = UDim2.fromScale(1, 0.4) }) -- card를 깔고 Size를 얹는다
local panel = q.Elem "Frame" { wide }

관련 — 테마와 동적 스타일링, 컴포넌트 경계 규약

예약 메소드(Peek/Apply/Overridden/As)와 캐스트 접두사(As + 대문자)를 뺀 문자열인 키는 전부 setter입니다. 클래스별 코드가 따로 생성되는 게 아니라 하나의 제네릭 __index가 그때그때 setter를 만들어 줍니다.

시그니처(타입드 Modifier에서의 모양 — quad-roblox라면 Modifier<C>)

<Field>: (self: Modifier<C>, value: ModifierField<T>) -> Modifier<C>

동작 — 인자에 따라 넷으로 갈립니다.

넘긴 것 되는 일
리터럴 값 그 필드를 그 값으로 덮어씁니다
State/Source 필드가 그 핸들 자체를 들고 반응형으로 남습니다
함수 변환입니다. 지금 값이 평범한 값이면 그 값을 인자로 지금 불러 결과를 저장하고, State면 old:Compute(fn)으로 파생 State를 만들어 반응성을 유지합니다 — 이 갈래에서는 Compute가 lazy라 setter 시점에 fn이 돌지 않고 첫 읽기(마운트)까지 미뤄집니다: fn이 던지거나 핸들러 층 값을 돌려주는 실수도 setter 줄이 아니라 마운트 줄(또는 이후 :Set)에서 드러납니다([2026-09-27 mock 실측])
nil 그 필드가 결과에서 사라집니다(가방에 아예 없음) — mod:X(if c then v else nil)처럼 조건부로 넘겨도 strict에서 그대로 됩니다
함수가 nil을 돌려줌 지금 값이 평범한 값이면 위 nil과 같이 필드가 사라지고(앞선 Modifier 값이 살아남), 지금 값이 State면 old:Compute(fn)이 State<nil> 필드를 만들어 앞선 값을 덮고 nil을 씁니다 — 두 갈래를 뜻하지 않았다면 q.None을 돌려주세요
함수가 함수를 돌려줌 지금 값이 평범한 값이면 setter 줄에서 거부합니다 — 필드는 함수를 들 수 없습니다(초기 필드 테이블의 Quad0063과 같은 규칙). State 갈래에서는 위 행대로 마운트 때 드러납니다
이벤트 이름 setter에 함수 핸들러가 아니라 변환으로 읽힙니다 — 생성 시점에 old = nil로 한 번 불리고 반환값이 nil이라 필드도 사라집니다(연결도 에러도 없음 — 핸들러를 돌려주는 변환이었다면 위 행의 에러). 이벤트 핸들러는 Modifier에 두지 않고, 꼭 필요하면 State에 담아 넘기세요(Quad0063 안내대로)
q.None 그 필드를 None으로 둡니다 — 명시적 해제이고, 유일한 “지우기” 표현입니다

⚠️ 변환 함수가 받는 old는 두 경우가 다릅니다. 지금 값이 평범한 값이면 그 값 자체가 오고, State면 :Compute로 넘어가므로 핸들이 옵니다 — 그때는 old:Get()으로 읽어야 합니다.

nil과 q.None의 차이가 핵심입니다. nil은 “이 Modifier는 이 필드에 관심 없음”(다른 Modifier나 인라인 키가 채울 수 있음)이고, q.None은 “이 필드를 비워라”라는 값입니다.

setter는 태그를 유지합니다 — 타입드 Modifier에 setter를 걸어도 클래스가 그대로 남습니다.

에러

  • Quad0057 Modifier: field "{key}" cannot hold a handler-layer value (Ref/Observer/Effect/Slot/Modifier/Spread/mapper descriptor)
  • Quad0063 Modifier: the transform for field "{key}" returned a function - a field cannot hold a function (an event handler does not belong in a Modifier; put it as an inline key on the element, or wrap it in a State)
  • Quad0058 Modifier: setter keys must be strings (got {typeof(key)}) - put a non-string key in the props table itself (Frame { [key] = value })

예제

local scale = q.Source(1)
local base = q.Modifier "Frame" { BackgroundTransparency = 0.5, LayoutOrder = 1 }
local dimmed = base:BackgroundTransparency(0.9) -- 리터럴
local live = base:LayoutOrder(scale) -- State 핸들 그대로
local cleared = base:BackgroundTransparency(q.None) -- 명시 해제
-- 변환: 지금 값이 평범한 값이면 그 값이 온다(필드가 없으면 nil)
local bumped = base:LayoutOrder(function(old) return (old :: number) + 1 end)
-- 변환: 지금 값이 State면 :Compute로 이어지고 old는 핸들이다
local doubled = live:LayoutOrder(function(old)
return (old :: q.StateData<number>):Get() * 2
end)
-- base 는 여전히 0.5 — 전부 새 값이다

그 필드가 아직 없으면 old는 nil입니다 — 타입도 ModifierFieldOut<T>?로 그걸 말합니다. --!strict에서는 old가 그 필드의 값 대수 전부라 위처럼 필요한 팔로 캐스트해야 합니다.

시그니처

Peek: <T>(self: Modifier, key: string) -> ModifierFieldOut<T>?
type ModifierFieldOut<T> = T | State<T> | None

인자

이름 타입 설명
key string 필드 이름(빈 문자열 불가)

반환 — 저장된 그대로의 값. 필드가 없으면 nil.

동작 — 절대 :Get()을 부르지 않습니다. State를 저장했으면 그 핸들이 그대로 나오고, None을 저장했으면 None이 나옵니다. setter의 변환 함수가 받는 old와 같은 타입입니다.

T 자리에는 그 필드의 값 대수 전부를 넣습니다. 백엔드 값 어휘가 있으면 그것까지 포함해야 합니다 — quad-roblox의 클래스 타입드 Modifier는 자기 Peek가 X | Tween<X>까지 덮도록 별칭을 씌워 두지만, 무타입 base Modifier()의 Peek는 위 정의를 그대로 쓰므로 Tween 팔이 없습니다. 그 필드가 Tween을 품을 수 있으면 mod:Peek<<UDim2 | Tween<UDim2>>>("Size")처럼 부르세요.

에러

  • Quad0049 Modifier:Peek: key must be a non-empty string (got {if key == "" then '""' else typeof(key)})

예제

local size = q.Source(UDim2.fromOffset(100, 40))
local mod = q.Raw.Modifier { Size = size, Visible = true }
local storedSize = mod:Peek<<UDim2>>("Size") -- Source 핸들 자체가 나온다
local visible = mod:Peek<<boolean>>("Visible") -- true
local missing = mod:Peek<<string>>("Text") -- nil

시그니처

Apply: <U>(self: any, factory: (any) -> U) -> U

인자

이름 타입 설명
factory (mod) -> U Modifier를 받아 무엇이든 돌려주는 함수

반환 — factory(self)의 결과 그대로.

동작 — 순수한 호출 슈거입니다. factory가 함수인지만 검사하고(Quad0050 Modifier:Apply: factory must be a function (got {typeof(factory)})), 그 밖의 계약도 추가 의미도 없습니다. 재사용 가능한 스타일 조합을 함수로 떼어 두고 체인 중간에 끼워 넣을 때 씁니다.

주석이 붙은 factory는 타입이 그대로 서고, 무주석 factory는 any가 됩니다.

예제

local function rounded(mod: any)
return mod:BorderSizePixel(0):BackgroundTransparency(0.1)
end
local styled = q.Raw.Modifier():Apply(rounded)

시그니처

Overridden: (self: Modifier, ...any) -> any

반환 — 합쳐진 새 Modifier(태그 true — 무타입).

동작 — 필드 단위 합성이고 뒤 인자가 이깁니다. 콜론 형태 a:Overridden(b)와 네임스페이스 형태 q.Raw.Modifier.Overridden(a, b)는 정확히 같은 함수입니다(자세한 계약은 q.Raw.Modifier.Overridden).

결과는 무타입이 됩니다 — 입력들의 태그가 서로 다를 수 있고 합성은 그중 하나를 고르지 않기 때문입니다. 클래스가 필요하면 mod:As<Class>()로 다시 태그하세요.

시그니처

As: <T>(self: Modifier, name: string?) -> T

인자

이름 타입 설명
name string? 태그를 바꿀 클래스 이름. 생략하면 값은 그대로 두고 타입만 캐스트

반환 — T(호출자가 명시).

동작 — 무검사 캐스트입니다. quad가 모르는 곳으로 억지로 보내는 탈출구입니다.

  • 인자가 없으면 값은 손대지 않고 타입만 바꿉니다 — mod:As<<MyModifier>>().
  • 이름을 주면 존재 검사만 하고 태그를 갈아 끼웁니다. 조상 관계는 보지 않습니다 — 그게 검사형 As<Class>()의 몫입니다.

에러

  • Quad0051 Modifier:As: name must be a class name string (got {typeof(name)})
  • Quad0052 Modifier: unknown modifier class "{name}" - Modifier.TypedFactory/DefineSubtype it first

시그니처

As<Class>: (self: Modifier<Ancestor>) -> Modifier<Class> -- 하위 클래스마다 하나씩(타입 함수가 계산)

반환 — 그 클래스로 태그된 새 Modifier.

동작 — 검사형 다운캐스트입니다. As 뒤에 대문자로 시작하는 이름을 붙인 모든 키가 여기로 갑니다(mod:AsFrame(), mod:AsTextButton()). 통과하려면 두 조건이 모두 맞아야 합니다.

  1. 대상 클래스가 등록돼 있어야 하고,
  2. 지금 Modifier의 태그가 셋 중 하나여야 합니다 — 무타입(true)이거나, 대상 클래스 자신이거나, 대상의 조상(여러 부모 경로 전부를 봅니다).

As + 대문자 접두사는 필드 이름으로 영원히 예약돼 있습니다. 그래서 오타 mod:AsTextLabl()은 조용히 AsTextLabl이라는 필드를 만드는 대신 에러가 됩니다. AspectRatio처럼 As 다음이 소문자면 접두사가 아니라 평범한 setter입니다.

에러

  • Quad0055 Modifier: unknown modifier class "{target}" - Modifier.TypedFactory/DefineSubtype it first (or use :As(name) for an unchecked cast)
  • Quad0056 Modifier: cannot cast a "{tag}" modifier to "{target}" - not an ancestor (use :As(name) to force)

예제

local common = q.Modifier "GuiObject" ():Visible(true) -- GuiObject 태그
local button = common:AsTextButton() -- 조상 → 자손: 통과

시그니처

Overridden: (...any) -> any

인자

이름 타입 설명
... Modifier 합칠 Modifier들. 하나 이상

반환 — 새 무타입 Modifier.

동작 — 필드 단위로 합치고 뒤 인자가 이깁니다. q.Raw.Modifier(...)의 병합 정책과 같지만, 이쪽은 Modifier만 받습니다(평범한 필드 테이블을 못 받습니다).

State/함수 분기가 없습니다 — 값은 이미 구워진 상태라 그대로 옮겨집니다.

에러

  • Quad0053 Modifier.Overridden: must be given at least one Modifier
  • Quad0054 Modifier.Overridden: argument #{i} is not a Modifier (got {typeof(arg)})

예제

local theme = q.Raw.Modifier { BackgroundTransparency = 0.2 }
local danger = q.Raw.Modifier { BackgroundTransparency = 0 }
local merged = q.Raw.Modifier.Overridden(theme, danger) -- danger가 이긴다
local same = theme:Overridden(danger) -- 같은 함수, 같은 결과

관련 — 테마와 동적 스타일링

시그니처

TypedFactory: <T>(name: string) -> (...(Modifier | { [string]: any })) -> T

인자

이름 타입 설명
name string Modifier 클래스 이름(빈 문자열 불가)

반환 — 그 클래스로 태그된 생성자. 호출 모양은 q.Raw.Modifier(...)와 같습니다.

동작 — 하는 일이 하나입니다. 이름을 등록해 알려진 클래스로 만들고, 그 태그를 심는 생성자를 돌려줍니다. 상속 관계는 아무것도 모릅니다 — 그건 DefineSubtype의 몫입니다.

공개 API입니다. 백엔드가 자기 엔진 클래스를 등록하는 데 첫 번째로 쓰지만, 컴포넌트 작성자가 자기 가상 클래스를 등록하는 길도 똑같습니다 — 커스텀 필드를 가진 MaterialButtonModifier도 런타임에서는 엔진 클래스의 Modifier와 같은 일급 값입니다(태그, As 검사, 병합). 타입에서는 다릅니다 — quad-roblox의 입력 자리 마커 ModifierInto<C>는 엔진 클래스 태그만 보므로, 커스텀 클래스 값은 :AsTextButton()처럼 그 클래스로 내려서 넘깁니다(roblox/03).

같은 이름으로 다시 부르면 같은 생성자 객체가 나옵니다(dedup). 두 모듈이 같은 클래스를 등록해도 안전합니다.

에러

  • Quad0066 Modifier.TypedFactory: name must be a non-empty class name string (got {typeof(name)})

예제

type CardModifier = {
read __quadModifier: "Card",
Elevation: (self: CardModifier, value: number) -> CardModifier,
Peek: <T>(self: CardModifier, key: string) -> QuadTypes.ModifierFieldOut<T>?,
As: <T>(self: CardModifier, name: string?) -> T,
}
local Card = q.Raw.Modifier.TypedFactory<<CardModifier>>("Card")
local raised = Card({ Elevation = 2 })

q.Raw.Modifier.DefineSubtype(parent, subtype)

섹션 제목: “q.Raw.Modifier.DefineSubtype(parent, subtype)”

시그니처

DefineSubtype: (parent: string, subtype: string) -> ()

인자

이름 타입 설명
parent string 상위 클래스 이름
subtype string 하위 클래스 이름

반환 — 없음.

동작 — 하는 일이 하나입니다. subtype ⊂ parent 간선 하나를 등록합니다. 두 이름 모두 그 자리에서 알려진 클래스가 되므로 등록 순서를 신경 쓸 필요가 없습니다.

  • 한 subtype이 부모를 여럿 가질 수 있습니다(인터페이스/다중 상속). Modifier 필드는 있거나 없거나 둘 중 하나라 충돌할 것이 없습니다.
  • 같은 간선을 두 번 등록하면 아무 일도 하지 않습니다.
  • 거부되는 것은 순환뿐입니다.

등록된 간선은 검사형 mod:As<Class>()가 조상 관계를 판정할 때 쓰입니다.

에러

  • Quad0066 Modifier.DefineSubtype: parent must be a non-empty class name string (got {typeof(name)})
  • Quad0066 Modifier.DefineSubtype: subtype must be a non-empty class name string (got {typeof(name)})
  • Quad0067 Modifier.DefineSubtype: cannot make "{subtype}" a subtype of "{parent}" - that would make a cycle

예제

q.Raw.Modifier.TypedFactory<<any>>("Clickable")
q.Raw.Modifier.TypedFactory<<any>>("Card")
q.Raw.Modifier.DefineSubtype("Clickable", "Card") -- Card ⊂ Clickable