How to make a class in Luau without having to define all the types

Let’s say I want to make a class, it will look something like this:

local Wheel = {}
Wheel.__index = Wheel

function Wheel.new(config)
	local self = setmetatable({}, Wheel)

	self.part = config.part
	self.isSteer = config.isSteer
	self.angularVelocity = 0

	return self
end

return Wheel

Easy, right? What about typing support? Let’s add it…

local Wheel: Types.Impl = {} :: Types.Impl
Wheel.__index = Wheel

function Wheel.new(config: Types.ConstructorConfig)
	local self = setmetatable({} :: Types.Proto, Wheel)

	self.part = config.part
	self.isSteer = config.isSteer
	self.angularVelocity = 0

	return self
end

That’s not all yet…

--!strict

local EngineDef = require(script.Parent.Parent.types.engine)

export type Impl = {
	__index: Impl,

	-- Constructor
	new: (config: ConstructorConfig) -> Wheel,

	-- External
	getAngularVelocity: (self: Wheel) -> number,
	getLength: (self: Wheel) -> number,
	getMaxLength: (self: Wheel) -> number,
	
	-- Internal
	_init: (self: Wheel, config: InitConfig) -> (),
	_applyForce: (self: Wheel, config: ForceInfo) -> (),
	_computePosRot: (self: Wheel, config: VisualInfo) -> ()
}

export type Proto = {
	-- External
	part: BasePart,
	isSteer: boolean,
	
	visualize: boolean,
	
	steer: number,
	rotation: number,
	angularVelocity: number,
	
	raycastParams: RaycastParams,
	positionOffset: CFrame,
	weld: Weld,
	
	radius: number,
	maxSteerAngle: number,
	friction: number,
	
	spring: SuspensionSpring,

	engine: EngineDef.Engine
}

export type ConstructorConfig = {
	part: BasePart,
	isSteer: boolean
}

export type InitConfig = {
	positionOffset: CFrame,
	raycastParams: RaycastParams,
	weld: Weld,

	radius: number,
	maxSteerAngle: number,
	friction: number,

	springHeight: number,
	springStiffness: number,
	springDamping: number,

	engine: EngineDef.Engine
}

export type SuspensionSpring = {
	length: number,
	maxLength: number,
	stiffness: number,
	damping: number
}

export type ForceInfo = {
	root: Part,
	deltaTime: number,
	rotationTransform: CFrame?,
	throttleFloat: number,
	throttle: number,
	torque: number,
	steerFloat: number,
	
	antiRollBarForce: number
}

export type VisualInfo = {
	root: Part,
	steerFloat: number,
	deltaTime: number
}

export type Wheel = typeof(setmetatable({} :: Proto, {} :: Impl))

return {}

Well, by this point you should see the issue, so how do I get typing support without this type declaration file monster? In C# it would be simple as what shown below (and it would get type support out of the box):

public struct Config {
	public double angularVelocity;
	public boolean isSteer;
	public Part part;
}

public class Wheel {
	public double angularVelocity;
	public boolean isSteer;
	public Part part;

	Wheel(Config config) {
		part = config.part;
		isSteer = config.isSteer;
	}
}

Unfortunately the type system is just a disaster because it was added as an afterthought years after lua was designed. No programming language has ever added a strict type system after the fact that was good.

1 Like

I really don’t wanna move to Unity just to be able to use a mature language such as C# that can work in large projects. I hope that there will be some kind of solution, but I doubt it unless it’s C# to Luau which will be another complex bandage fix.

There are ways to write your code without using object oriented paradigms and thats more or less what I’ve resorted to. Its difficult to see how at first, but I can explain if you give me a specific example you’re trying to do, the only difference is it will not have the type safety that you want, which is what a real strongly typed language like C# would offer you. That’s fine, and in a large project the effect is mostly that your documentation will have to be stronger to prevent people (including yourself) from using your code wrong.

Writing a chassis system, it has 3 components:

  • Chassis
  • Engine
  • Wheel

Those 3 work together to solve physics for raycast based car.

In the most general sense how do you want these to communicate with each other? Like what information needs to go in what direction? Also what is the lifecycle of the whole system? For example does it get set up once when the car loads, and does it ever need to update / change components or their features?

Index file communicates with the 3 components, it sets them all up. Then wheel can use chassis and engine to determinate it’s velocity, position, rotation, etc. Here is a basic overview of the API prototype:

1 Like

Well, this is how I currently annotate my classes:

local Car = {}
Car.__index = Car

type self = {
	Speed: number
}

export type Car = typeof(setmetatable({} :: self, Car))

function Car.new(): Car
	return setmetatable({
		Speed = 50
	}, Car)
end

function Car.Boost(self: Car): ()
	self.Speed += 50
end

return Car

Tables can only go so far in representing objects, so that’s why the idiom is so blotchy. But hopefully when records finally get added things will become easier.

4 Likes

You could still have each of these three components be members of a table and just ignore type safety, since you can store functions in tables. You probably want to do something to make it more organized though. Objects in an OOP language are just a collection of state and usually their member functions (if they aren’t static, which lua has no concept of) mutate that state. To get a similar effect you create a system of functions for working with the objects that make it difficult to ever make their state “wrong”. This can provide the same safety as strong typing if you always obey some rule about what functions operate on what components. For example it looks like a lot of Wheel’s properties are also the state of something in the workspace, so you should have some kind of CreateWheel function that copies these properties from the actual spring / wheel part, that way they can’t be wrong if you change those properties later.

As for communication, you have a car controller, which looks like it should have one Engine state, one Chasis state, and presumably four Wheel states. You should design any communication functions between CarController and the three components so that, even if you didn’t know what code Wheel/Engine/Chasis were using and could only interact with them via your functions, it should be very difficult to mess up each of their states.

Further rambling: No language can relieve you of the need to maintain your code, since your idea of what the system should do will evolve with time, you can only take steps to make that eventual reorganization easy. This is something a lot of people struggle with if they are exposed exclusively to OOP, they might think there is only one “right” piece of code that they will eventually end up at, and so spend a lot of time trying to get an object/class Perfectly Safe, but will inevitably have to tear things down just as much later.

1 Like

I’ve seen so many different approaches trying to properly define fields of a class, but this one is just epic. Simple and effective. Thanks a lot for sharing. I’ll use it from now on in my luau “classes”.