I’ll set up an analogy here since my original code is somewhat more complex.
The scenario
Below, I have a FuelPackage module which
- Defines a sort of “Interface” which all Fuel shares,
IFuel. - Defines some types of Fuel, like
CoalandFirewood. - Defines a union which encompasses all possible fuel types,
Fuel. - Defines a FuelPackage type, as well as a FuelPackage class with a
.newconstructor. FuelPackages will be our analogy here - a type that contains a value inside of it, PackagedFuel, which is of typeFuel. - FuelPackage’s constructor should only ever be passed a
Fuel, so it will only ever contain aFuelinside of it. If we tried to pass astringfor example, the linter would warn us, because astringis not aFuel.
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
- We create a new
Firewoodobject, and then pass it into the constructor forFuelPackage, which gives us a package that contains theFirewoodobject we passed into it. - However, our
fuelvariable cannot be converted into theFirewoodtype, even though we know it’sFirewood. - 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
- Have the constraints of using Unions, so that FuelPackage will only accept types of fuels, which all inherit
IFuel. - Be able to take advantage of generics, so that if I pass
FirewoodintoFuelPackage.new, it would remember that thepackage.PackagedFuelisFirewood, instead of the general unionFuel.
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!

