Forest Logo
search
package_2

classy

By @jaeymo

Roblox

Mirrored from Wally
[image: Classy]

A lifecycle manager for tagged instances in Luau

Make composition and OOP architecture feel good in Roblox.

Wally License: MIT Luau

Version Stars Issues Last commit PRs welcome

Quick Start · Examples · Settings · Lifecycle · API · Contributing


[!IMPORTANT] Classy is actively being worked on and features are constantly being added!

✨ What Is Classy?

Classy is a lifecycle manager built on top of CollectionService that makes both composition and OOP architecture more appealing in Luau.

It is the cleaner, strictly-typed, smarter iteration of the original package, Wrapper, which is now archived. If you're using Wrapper, switch to Classy.

🚀 Why Use Classy?

FeatureWhat you get
🧩Two construction stylesTrack instances with plain functions or full classes
🧹Automatic cleanupHandles the class object's metatable and the provided Janitor
🔒Full type safetyGenerics are preserved when accessing applied objects
🏛️OOP-friendlyWrite classes for game objects, then track them with tags
🧱Composition-friendlyRegister and fetch attached components (ClassyObjects) from an instance
⚡Tiny APIA whole system can fit in a few lines

👀 A Taste

Isn't this great? A quick shorthand:

-- All parts with the "KillPart" tag will now kill anything with a humanoid!
Classy.new("KillPart", function(instance: BasePart, janitor: Classy.Janitor)
	janitor:Add(instance.Touched:Connect(function(hit: BasePart)
		local humanoid = hit.Parent and hit.Parent:FindFirstChildWhichIsA("Humanoid")
		if not humanoid then
			return
		end

		humanoid.Health = 0
	end))
end, {
	ClassNames = { "BasePart" },
	Ancestors = { workspace },
}):Init()

📦 Quick Start

Install

Wally (recommended)

Add Classy to your wally.toml:

[dependencies]
Classy = "jaeymo/classy@2.2.0"

Then run:

wally install

Or grab it from the Wally package page.

Dependencies

PackagePurpose
JanitorCleanup of connections and objects
SignalEvents like InstanceAdded

🧪 Examples

Classy's best feature is that you can use function-based or class-based construction, and both build the same underlying system. Here's the class used in the examples below:

📄 KillPartClass (click to expand)
--!strict

local Classy = require(path.to.classy)

local KillPartClass = {}
KillPartClass.__index = KillPartClass

export type KillPart = typeof(setmetatable({} :: {
	Part: BasePart,
	Janitor: Classy.Janitor,
}, KillPartClass))

function KillPartClass.new(part: Instance, janitor: Classy.Janitor): KillPart
	return setmetatable({
		Part = part :: BasePart,
		Janitor = janitor,
	}, KillPartClass)
end

function KillPartClass.Init(self: KillPart)
	self.Janitor:Add(self.Part.Touched:Connect(function(hit: BasePart)
		local humanoid = hit.Parent and hit.Parent:FindFirstChildWhichIsA("Humanoid")

		if not humanoid then
			return
		end

		humanoid.Health = 0
	end))
end

function KillPartClass.DoSomething(self: KillPart)
	print(self)
end

function KillPartClass.Destroy(self: KillPart)
	print("This object has been destroyed!")
end

🏛️ Class-based construction

local KillPartClassy = Classy.newClass("KillPart", KillPartClass, {
	ClassNames = { "BasePart" },
	Ancestors = { workspace },
	Logging = true,
})

🧩 Function-based construction

local AnotherExample = Classy.new("KillPart", function(instance: Instance, janitor: Classy.Janitor)
	return KillPartClass.new(instance, janitor)
end, {
	ClassNames = { "BasePart" },
	Ancestors = { workspace },
	Logging = true,
})

▶️ Initialize

KillPartClassy:Init()
AnotherExample:Init()

🔌 Interact with applied objects

Once initialized, you can interact with applied objects directly. ObserveApplied covers both instances that are already applied and ones added later:

KillPartClassy:ObserveApplied(function(instance, applied)
	applied:GetData():DoSomething()
end)

Prefer a raw signal? KillPartClassy.InstanceAdded fires only for instances applied from now on, and InstanceRevoked fires when they are removed.

[!TIP] Building something simple that doesn't need a class? Use the shorthand from A Taste. It's the same system with less boilerplate.

⚙️ Settings

Both Classy.new and Classy.newClass accept an optional Config table (ClassyConfig) as their last argument. Every option is optional:

OptionTypeDefaultDescription
ClassNames{ string }{}Instances must pass IsA for at least one listed class name. Empty means no restriction
Ancestors{ Instance }{}Instances must be a descendant of at least one listed ancestor. Empty means no restriction
Predicate(Instance) -> booleannilCustom check. Return false to skip an instance
LoggingbooleanfalsePrints when instances are applied, revoked, and cleaned up
RemoveTagOnCleanupbooleantrueRemoves the tag from the instance when it is revoked (including when the Classy is destroyed)
NukebooleantrueWhen an applied object is destroyed, also clears the table your constructor returned and removes its metatable
NamingConvention{ Init: string, Destroy: string }{ Init = "Init", Destroy = "Destroy" }Method names Classy calls on your object for its lifecycle
WaitForMetadatabooleanfalseWaits up to 7 seconds for an instance's metadata attribute before applying it
Context{ any }{}Table handed to your constructor as its third argument
AutoInitbooleantrueCalls your object's Init method automatically after it is registered. When false, call it yourself. Initialized is then true as soon as the object is registered
Classy.newClass("Door", DoorClass, {
	ClassNames = { "Model" },
	Ancestors = { workspace.Doors },
	Predicate = function(instance)
		return instance:GetAttribute("Enabled") ~= false
	end,
	NamingConvention = { Init = "Start", Destroy = "Cleanup" },
	WaitForMetadata = true,
	Logging = true,
}):Init()

[!NOTE] Lifecycle methods are automatic. After your object is constructed and registered, Classy calls its Init method (if it has one), and calls Destroy when the instance is revoked. Rename them with NamingConvention. Because the object is registered first, Init can find its own object with getComponent or GetApplied. See Lifecycle for the full order and what happens on failure.

[!NOTE] When checks run. ClassNames, Ancestors, and Predicate are checked when an instance gets the tag (or joins the game with it) and again whenever an applied instance's ancestry changes. An instance that stops passing is revoked, and it is not re-applied automatically if it passes again later. Re-tag it or call :Apply yourself.

🔄 Lifecycle

Applying an instance (from its tag, :Apply, or applyComponents) runs these steps in order:

  1. Construct: your function, or your class's new, runs.
  2. Register: the object becomes available through GetApplied, GetAll, and getComponent.
  3. Init: your Init method runs (skipped if AutoInit = false or the method doesn't exist).
  4. Initialized: Initialized becomes true and InstanceAdded fires.

Your constructor and Init are allowed to yield. Classy handles the cases that creates:

SituationWhat happens
:Apply called again while the constructor is runningThe second call waits and returns the same object
:Apply called again while Init is runningReturns the existing object. Check .Initialized if you need it finished
Revoked while the constructor is runningThe apply is cancelled and the finished object is destroyed. :Apply returns nil
Revoked while Init is runningRevoked normally. :Apply returns nil
Constructor errorsClassy cleans up and rethrows the error
Init errorsThe object is revoked (your Destroy runs, the tag is removed) and the error is rethrown with a traceback

ObserveApplied only hands you initialized objects. Ones still in Init arrive through InstanceAdded when they finish.

[!NOTE] If the instance is revoked while Init is running and Init then errors, the error is not rethrown and :Apply returns nil. The error is only logged as a warning when Logging is on.

[!TIP] Since Destroy also runs after a failed Init, write it so it tolerates a half-built object.

📚 API

Constructors

FunctionReturnsDescription
Classy.new(Tag, Constructable, Config?)Classy<T>Function-based construction
Classy.newClass(Tag, Constructable, Config?)Classy<T>Class-based construction

Your function (or your class's new) is called with the same four arguments:

#ArgumentDescription
1InstanceThe tagged instance
2JanitorA Janitor that is cleaned up when the instance is revoked
3ContextThe Context table from your config
4Metadata?The metadata the instance was applied with, if any
Classy.new("Door", function(instance, janitor, context, metadata)
	-- ...
end)

function DoorClass.new(instance, janitor, context, metadata)
	-- ...
end

Working with components

Every applied object is registered on its instance, so you can fetch and drive an instance's components from anywhere.

FunctionReturnsDescription
Classy.getComponent(Inst, Tag)Applied<any>?Get the component applied to Inst under Tag
Classy.getComponents(Inst){ [string]: Applied<any> }?Get every component applied to Inst, keyed by tag
Classy.callOnAllComponents(Inst, MethodName, ...)Calls MethodName (with ...) on every component of Inst that has that method
Classy.applyComponents(Inst, Components)Applies each listed component to Inst (and tags it), passing its metadata. Each tag must be registered globally
Classy.emit(Inst, TriggerName, Context?)Fires a trigger; components that map TriggerName to a method get that method called with Context
-- Grab a specific component and use it
local killPart = Classy.getComponent(workspace.Lava, "KillPart")
if killPart then
	killPart:GetData():DoSomething()
end

-- Call a method on every component the instance has
Classy.callOnAllComponents(workspace.Lava, "DoSomething")

-- Attach components at runtime, with per-component metadata.
-- The tag must be registered globally first!
Classy.registerGlobal("KillPart", KillPartClassy)

Classy.applyComponents(workspace.Lava, {
	KillPart = { Damage = 25 },
})

-- Fire a trigger
Classy.emit(workspace.Lava, "Activated", { Source = "Lever" })

[!NOTE] getComponent and GetApplied can return an object whose Init is still running. Check .Initialized if you need it finished.

Triggers

A component can declare triggers in its metadata under Triggers, mapping a trigger name to one of its methods. Classy.emit then calls that method with the context you pass:

Classy.registerGlobal("Door", DoorClassy)

Classy.applyComponents(door, {
	Door = {
		Triggers = { Activated = "Open" },
	},
})

-- Calls door's Door component: Data:Open({ Source = "Lever" })
Classy.emit(door, "Activated", { Source = "Lever" })

Component aliases

Metadata can include UseComponent (or useComponent) naming another globally registered component. Classy builds a copy of that component's Classy for the new tag, so one constructor can power many differently-configured components:

Classy.registerGlobal("Inventory", InventoryClassy)
Classy.registerGlobal("Resource", ResourceClassy)

Classy.applyComponents(player, {
	Inventory = { MaxItemAmount = 20 },

	-- Built from the Resource component instead of an "Ammo" Classy
	Ammo = {
		UseComponent = "Resource",
		StartingAmount = 0,
		MaxCount = 0,
	},
})

The alias reuses the base's constructor and config (ClassNames, AutoInit, Ancestors, Predicate, NamingConvention, Logging, RemoveTagOnCleanup, Nuke, WaitForMetadata) and gets its own tag. Its Context is a copy of the base's with tag set to the alias tag. If the base isn't registered, Classy warns and skips the component.

Metadata

Metadata passed to Apply or applyComponents is handed to your constructor as its fourth argument and saved on the instance as a JSON attribute named _<lowercase tag>_metadata (for example _door_metadata), so it must only contain JSON-serializable values (no functions or Instances). With WaitForMetadata = true, Classy waits up to 7 seconds for that attribute to exist before applying, which lets you tag an instance first and set its metadata attribute (as a JSON string) afterwards.

Global registry

FunctionReturnsDescription
Classy.registerGlobal(Tag, ClassyInstance)Register a Classy object globally under Tag. Required for applyComponents and UseComponent
Classy.getGloballyRegistered(Tag)Classy<any>?Look up a globally registered Classy object

Classy objects

What Classy.new and Classy.newClass return.

MemberReturnsDescription
:Init()Applies all currently tagged instances that pass the checks and starts listening for new and removed ones. Call it once
:CanBeApplied(Inst)booleanWhether Inst passes ClassNames, Ancestors, and Predicate
:Apply(Inst, Metadata?)Applied<T>?Applies Inst, bypassing CanBeApplied. Returns the existing one if already applied, and waits if another call is mid-construction. Returns nil if the instance is revoked before it finishes. Errors from your constructor or Init are rethrown
:Revoke(Inst)Destroys the applied object, removes it from Classy, and removes the tag (unless RemoveTagOnCleanup = false). Cancels the apply instead if the constructor is still running
:ObserveApplied(Callback)ConnectionRuns Callback(Instance, Applied) for every existing initialized object and every future one
.InstanceAddedSignal<Instance, Applied<T>>Fires once an instance is applied and its Init has finished
.InstanceRevokedSignal<Instance>Fires after an instance is revoked. Can fire for an object that never fired InstanceAdded (revoked during, or after a failed, Init)
:GetApplied(Inst)Applied<T>?Get the applied object for Inst, if any
:GetAll(){ [Instance]: Applied<T> }Every applied object
:Destroy()Revokes everything, cleans up its Janitor and signals, and makes the Classy unusable
local applied = KillPartClassy:Apply(workspace.Lava)
if applied then
	applied:GetData():DoSomething()
end
local connection = KillPartClassy:ObserveApplied(function(instance, applied)
	applied:GetData():DoSomething()
end)

-- Later
connection:Disconnect()

[!WARNING] Revoking clears the Applied wrapper, and Classy:Destroy() clears the Classy, so don't use either afterwards. With Nuke = true (the default), revoking also clears your object's table and removes its metatable. Set Nuke = false if you need your object to stay intact after it's revoked. Don't call Classy:Destroy() while an instance is still inside its constructor.

Applied objects

The wrapper Classy keeps for each instance.

MemberDescription
:GetData()Returns what your constructor or class produced
:Destroy()Runs your Destroy method, cleans its Janitor, and clears the object. Called by Revoke, so prefer Classy:Revoke(Inst) over calling this yourself
.InstanceThe instance it's attached to
.ClassyThe Classy that owns it
.JanitorThe Janitor handed to your constructor
.DataSame as :GetData()
.TriggersThe trigger map from its metadata, if any
.Initializedtrue once Init has finished. Always true when AutoInit = false

🤝 Contributing

Contributions, issues, and feature requests are welcome!

  1. Fork the repo
  2. Create your branch: git checkout -b feature/cool-thing
  3. Commit your changes: git commit -m "Add cool thing"
  4. Push and open a Pull Request

Check the issues page for things to work on.

📄 License

Released under the MIT License.


If Classy helps you, consider leaving a ⭐. It really helps!

Package Details

Install command (Click to copy)


Version

2.2.0

License

MIT

check_circle

Safe for commercial use

Automated license review — not legal advice.