Luau typechecking: Constrained generics - best alternative?

I’ll set up an analogy here since my original code is somewhat more complex.

The scenario

Below, I have a FuelPackage module which

  1. Defines a sort of “Interface” which all Fuel shares, IFuel.
  2. Defines some types of Fuel, like Coal and Firewood.
  3. Defines a union which encompasses all possible fuel types, Fuel.
  4. Defines a FuelPackage type, as well as a FuelPackage class with a .new constructor. FuelPackages will be our analogy here - a type that contains a value inside of it, PackagedFuel, which is of type Fuel.
  5. FuelPackage’s constructor should only ever be passed a Fuel, so it will only ever contain a Fuel inside of it. If we tried to pass a string for example, the linter would warn us, because a string is not a Fuel.
FuelPackage class
--!strict

-- Type declarations
type IFuel = {
	Name: string,
	Strength: number
}
export type Coal = IFuel & {
	Density: number
}
export type Firewood = IFuel & {
	Dampness: number
}
export type Fuel = Coal | Firewood
type FuelPackage = {
	PackagedFuel: Fuel,
}

-----------------------------------------------------------

local FuelPackage = {}
FuelPackage.__index = FuelPackage

function FuelPackage.new(Fuel: Fuel): FuelPackage
	local Self = setmetatable({
		PackagedFuel = Fuel
	}, FuelPackage)
	
	return Self
end

return FuelPackage

Here’s some code where we’re interacting with this FuelPackage class:

--!strict

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local FuelPackage = require(ReplicatedStorage.Shared.FuelPackage)

local Firewood: FuelPackage.Firewood = {
	Name = "Firewood",
	Strength = 15,
	Dampness = 0.34
}

local package = FuelPackage.new(Firewood)

local fuel: FuelPackage.Firewood = package.PackagedFuel
  1. We create a new Firewood object, and then pass it into the constructor for FuelPackage, which gives us a package that contains the Firewood object we passed into it.
  2. However, our fuel variable cannot be converted into the Firewood type, even though we know it’s Firewood.
  3. This is because without generics, we lose which type we passed in, because the constructor returns a FuelPackage type, which has a Fuel type inside of it.

Now, let’s try implementing this same code, but with generics!

Here’s the FuelPackage module now:

FuelPackage class (with generics)
--!strict

-- Type declarations
type IFuel = {
	Name: string,
	Strength: number
}
export type Coal = IFuel & {
	Density: number
}
export type Firewood = IFuel & {
	Dampness: number
}
export type Fuel = Coal | Firewood
type FuelPackage<TFuel> = {
	PackagedFuel: TFuel,
}

-----------------------------------------------------------

local FuelPackage = {}
FuelPackage.__index = FuelPackage

function FuelPackage.new<TFuel>(Fuel: TFuel): FuelPackage<TFuel>
	local Self = setmetatable({
		PackagedFuel = Fuel
	}, FuelPackage)
	
	return Self
end

return FuelPackage

This causes our earlier code to no longer warn us at this statement, because with generics, it will remember that whatever is returned from FuelPackage, will remember what we passed into it, by keeping track of the generic type, TFuel.


The problem

While this works for very bare-bones objects, once we start referencing information about the Fuel (typically something all fuel would have in common, like IFuel’s Name & Strength fields) inside of the FuelPackage class, you can see where it would run into issues..

Even though I expect to only pass a generic type which is constrained to Fuel (like Firewood or Coal, since these are the only fuels I plan to package), the linter doesn’t know this, because by default, generics can accept any value.

In the end, I would like to have something similar to the setup shown here, but

  1. Have the constraints of using Unions, so that FuelPackage will only accept types of fuels, which all inherit IFuel.
  2. Be able to take advantage of generics, so that if I pass Firewood into FuelPackage.new, it would remember that the package.PackagedFuel is Firewood, instead of the general union Fuel.

Final thoughts

Because unions don’t truly stop certain types going in-and-out of a function, they exist to simply set a standard of how your code should ideally be used, using typeof is out of the question when making assumptions, and I’m unsure of if the linter would even pick it up or not.

Attempting to cast a TFuel into IFuel directly, will still give an error, because they could be unrelated in the first place. TFuel is too generic, and I don’t know how to constrain the value of TFuel, while having the benefits of generics to retain the value sent through functions.

If anyone could help drive me to the right direction of how they would handle code similar to mine, I would appreciate the feedback!

if that’s the case, you can do this:

function FuelPackage.new<TFuel>(Fuel: TFuel & IFuel): FuelPackage<TFuel>

With this, you’re making sure the Fuel parameter is also an IFuel, effectively constraining TFuel.

1 Like

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.