Why don't I get proper typechecking for classes created with a function?

Hello!

I’m trying to write some code to help me define classes quicker, and I was hoping for the created classes to have typechecking support.

--!strict

local Class = {}

--MN: MethodName
--M : Method
--PN: ParamName
--P : Param
type _Prototype<MN, M, PN, P> = {
	__index: _Prototype<MN, M, PN, P>,
	className: string,
	new: ({ [PN]: P }) -> _ClassType<MN, M, PN, P>,
	IsA: (_ClassType<MN, M, PN, P>, string) -> boolean,
	[MN]: M,
}

type _ConstructorPattern<PN, P> = { [PN]: P }

type _ClassType<MN, M, PN, P> = typeof(setmetatable({} :: _ConstructorPattern<PN, P>, {} :: _Prototype<MN, M, PN, P>))

function Class.new<MN, M, PN, P>(className: string, impl: { [MN]: M }, desc: { [PN]: P })
	local newClass = {} :: _Prototype<MN, M, PN, P>
	newClass.__index = newClass
	newClass.className = className

	function newClass.new(descriptor: _ConstructorPattern<PN, P>): _ClassType<MN, M, PN, P>
		local self = setmetatable({} :: _ConstructorPattern<PN, P>, newClass)
		for paramName, value in pairs(descriptor) do
			self[paramName] = value
		end
		return self
	end

	function newClass:IsA(className: string): boolean
		return newClass.className == className
	end

	for methodName, method in pairs(impl) do
		newClass[methodName] = method
	end

	return newClass
end

However, the new classes don’t have any of the typechecking that I would expect to see based on the generics:

type desc = {
	myBool: boolean,
}

local impl = {}

function impl:test()
	return self.myBool
end

local myClass = Class.new("MyClass", impl, {} :: desc)

local myClassInstance = myClass.new({ myBool = false })
-- doesn't check that all fields of the table are present
print(myClassInstance.myBool)
-- doesn't autofill myBool
myClassInstance:test()
-- doesn't autofill test

Is there something I’m doing wrong? Or is this just a limitation of the typechecker?

I think it’s a limitation of the typechecker. I don’t 100% know why it’s happening but I have a couple guesses. You pass in {} :: desc but I don’t think generics deal with string literals and so this is probably getting generalized as a {[string]: boolean}

type _ConstructorPattern<PN, P> = { [PN]: P }

When you make types like this, I don’t think the type checker can ever show you what is in the table at any given time since you’ve specified it can take anything so long as keys are type PN and the values are type P

--!strict
-- Typechecker will show me what fields are in this table. The type is { ValueA: number, ValueB: number }
local x = {
	ValueA = 10,
	ValueB = 20
}

-- Typechecker won't show me what fields are in this table. It can only ensure the correct types are used.
local y: {[string]: number} = {
	ValueA = 10,
	ValueB = 20
}

Ahh okay. I had made the _ConstructorPattern type that way because setmetatable would complain that I wasn’t passing a table when I tried something like the following:

-- TypeError: setmetatable should take a table
type _ClassType<A, B> = typeof(setmetatable({} :: A,{} :: B))

Maybe something like this would work if we had constrained generics, or perhaps there is a workaround somewhere.

As for generics and string literals, they seems to work but it could be my example here is too simple

function x<T>(a: T): T
	return a :: T
end

local y = {
	ValueA = 10,
	ValueB = 20,
}

-- z: {ValueA: number, ValueB: number}
local z = x(y)

Edit: Going to mark your response as the solution, but if anyone has a workaround for using setmetatable with generics that would be very helpful

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