Type Annotations! A guide to writing Luau code that is actually good

Welcome!

This tutorial aims to help people understand Luau’s type annotation feature, and how to write code that’s smarter and easier for other experienced developers to pick up and understand within Roblox’s ecosystem today!

IMPORTANT CAVEAT

Roblox Studio has a bug where hovering over instance variables will sometimes misreport their typename as any. Fortunately studio’s autocomplete does recognize what the types are supposed to be, you just may have to manually annotate what their type is to make sure things work correctly.

This tutorial will assume one of two things:

  1. You’re using Luau in Visual Studio Code, which handles this correctly.
    (Requires the Luau Language Server extension)

  2. Roblox has enabled their “Luau Knows The DataModel Feature
    (via FFlagLuauKnowsTheDataModel3)


What are Type Annotations?

Type annotations are a feature of Luau that allow you to define constraints to the variables in your code. They provide contextual clues to how your code is intended to be used, and blueprints for how to structure data. When these constraints are violated, warnings will be emitted by Luau’s analyzer suggesting that you fix them.

To make the most effective use of type annotations, it’s highly recommended that you add this comment to the top of your script:

--!strict

It ensures variables will always try to give you a properly inferred type instead of falling back to the default “any” type.

Basic Usage

When you mouse over a variable, a tooltip should appear that describes what the variable’s type is.

local value = 0 -- Should report as [number]
local part = Instance.new("Part") -- Should report as [Part]

You can explicitly define the type of a variable by writing them as such:

local value: number = 0
local part: Part = Instance.new("Part")

When annotations are explicitly defined, they will emit warnings if you attempt to assign values to them which don’t match their defined types:

value = 5 -- good!
value = "lol" -- bad!

part = workspace -- bad!
part = Instance.new("SpawnLocation") -- good!

Null Types and Refinement

Type names can be suffixed with a question mark (?) to mark them as potentially nil. For example, Instance:FindFirstChild returns a type of Instance? to indicate that it may not have found a child with the specified name.

-- `inst` has the inferred type: [Instance?]
local inst = workspace:FindFirstChild("Instance") 

Attempting to index the fields of a nullable type will produce a warning. You must conditionally evaluate the variable’s existence. The nice thing is that Luau will automatically refine the type on the spot when this evaluation happens.

Consider the following example:

local maybe: number? = nil -- (Imagine this is assigned a value somewhere)

There are several ways to refine the nullability of this type:

Option 1: Inline refinement

-- `maybePlus1` is evaluated as [number?]
local maybePlus1 = (maybe and maybe + 1) -- `maybe` refines to [number] past `and`

Option 2: If statement refinement

if maybePlus1 then
    -- `maybePlus1` refines to: [number] in this body.
    print("maybePlus1 is defined!", maybePlus1)
else
    -- `maybePlus1` is now refined to: [nil]
    print("maybePlus1 is nil!")
end

Option 3: Return (or break/continue) refinement

if not maybePlus1 then
    return
end

-- `maybePlus1` is now refined to: [number]
print("the maybePlus1 is real!!!", maybePlus1)

Option 4: Assertion refinement

assert(maybePlus1, "maybePlus1 is not defined!") -- Will error if maybePlus1 is nil!

-- maybePlus1 is now refined to: [number]
print("maybePlus2:", maybePlus1 + 1)

Built-in Types

Luau has 9 built-in type names defined:

  • nil
  • string
  • number
  • thread
  • boolean
  • buffer
  • any
  • never
  • unknown

There are types for function and table as well, but they have a different structure which will be described later.

The last 3 types: any, never, and unknown, are special in that they don’t represent specific primitive types in Luau’s VM. They instead represent certain sets of all types in Luau:

any

any can store any type of value, effectively muting the type annotation system. You shouldn’t use this unless you have no choice.

local value: any = 0
value = "lol" -- valid!
value = nil -- valid!
value = 2 -- valid!

unknown

unknown must have it’s type evaluated through the type/typeof functions in order to be stored or used as any other variable type besides unknown. However, it can still be assigned to any value directly:

local value: unknown
value = "lol" -- valid!
value = nil -- valid!
value = 2 -- valid!

if type(value) == "number" then
    -- `value` is [number] in here
elseif type(value) == "string" then
    -- `value` is [string] in here
else
    -- `value` is [unknown] in here
end

never

never cannot be refined into any type. It’s usually not defined directly, instead appearing when attempting to do a type refinement deemed impossible:

local value: unknown = true

if type(value) == "buffer" and type(value) == "boolean" then
    -- `value is now [never] because it cannot
    -- be a buffer and a boolean at the same time!
    print("This message will never print!", value)
end

Concrete Types

Roblox’s Luau environment defines “concrete” types for each defined engine type in the API Reference.

Concrete types are statically defined and can extend from one another, but they cannot be defined by Luau code directly. They are declared by the Luau vendor (i.e. Roblox) at runtime.

Concrete types can be refined from any/unknown through the use of Luau’s typeof function:

local object: unknown

if typeof(object) == "Instance" then
    -- `object` is now refined to [Instance]
    print("object is an Instance:", object.Name)
else
    -- `object` is still [unknown]
    print("object type is not an Instance:", object)
end

All of Roblox’s top-level concrete types are listed in the datatypes documentation.

Refining Concrete Types via “Magic Functions”

Consider the following:

local part = Instance.new("Part")
print(typeof(part)) -- prints "Instance"

Why does this print Instance instead of Part? Well, it turns out the typeof function will only describe top-level concrete types!

For concrete types that are extensions of top-level concrete types, you’ll need to use certain magic functions in Luau to refine these extended types. (“Magic” in this case being a hack on the C++ end, but it works™)

An example of this is Roblox’s Instance:IsA function:

 -- `object` starts as [Instance?]
local object = workspace:FindFirstChild("Terrain")

if object then
    -- `object` is now refined to [Instance]
    print(object.Name)

    if object:IsA("BasePart") then
        -- `object` is now refined to [BasePart]
        print(object.Size)

        if object:IsA("Terrain") then
            -- `object` is now refined to [Terrain]
            print(object:CountCells())
        end

        -- `object` is once again [BasePart]
        print(object.Position)
    end

    -- `object` is once again [Instance]
    print(object.Parent)
end

-- `object` is once again [Instance?]
print(object ~= nil)

Function Types

Arguments

When declaring functions, you can annotate their arguments with types to make them more explicitly defined:

local function addNumbers(a: number, b: number)
    return a + b
end

addNumbers(5, 2) -- valid!
addNumbers(1) -- needs two arguments!
addNumbers("a", "b") -- argument types are wrong!

Return Types

You can explicitly annotate a function’s return type:

local function toVector2(vec3: Vector3): Vector2
    return Vector2.new(vec3.X, vec3.Y)
end

local a: Vector2 = toVector2(Vector3.one) -- valid!
local b: number = toVector2(Vector3.one) -- wrong type for b!
local c = toVector2("lol") -- wrong argument type for vec3!

This is helpful when authoring a function knowing what it will return in advance. Luau will make sure to enforce that you’ve returned the correct type:

-- "not implemented yet!"
local function getNumber(object: Instance, name: string): number
    -- A warning will appear reporting that the 
    -- function doesn't always return a `number`
end

Tuples

If you have a function that returns multiple values, you can wrap their return types in parentheses comma-separated as such:

local function getCFrameAndSize(part: BasePart?): (CFrame?, Vector3?)
    if part then
        return part.CFrame, part.Size
    end

    return -- explicit return is required
end

If your function doesn’t return anything, you can leave the contents of the parentheses empty:

local function reportIfPartFound(message: string): ()
    if workspace:FindFirstChildOfClass("Part") then
        print(message)
    end
end

Variadics

You can typecheck variadic functions by annotating the ... argument of the function:

local function debugPrint(...: unknown)
    if DEBUG then
        print(...)
    end
end

Functions that return a variable amount of some type can annotated as ...T

local function gimmeSomeNumbers(): ...number
    if os.clock() % 2 > 1 then
        return 1, 2, 3
    else
        return 4, 5, 6, 7
    end
end

Function Type Annotating

A function variable is type annotated by wrapping the typenames of the function’s arguments and return types in parenthesis, separated by an arrow ->.

One basic example to start with is a function that takes no arguments and returns nothing. It would use the following type name: () -> ()

local noArgsOrReturn: () -> () = function()
	print("This function takes no arguments and returns nothing")
end

-- valid!
noArgsOrReturn = function()
	print("We can reassign the variable since we know its type!")
end

-- bad assignment: cannot convert a function with 1 arg into a function with no args.
noArgsOrReturn = function(arg: any)
	print(arg, "????")
end

-- ok, but invalid in practice. see below.
noArgsOrReturn = function()
	return "lol"
end

-- attempts to assign a variable to the "return" of this function
-- will warn that it's not supposed to return anything.
local value = noArgsOrReturn()

-- attempting to call it with arguments will also report
-- that it's not supposed to take arguments.
noArgsOrReturn("lol")

Annotating Arguments and Returns

Function arguments and return types are defined in a similar way to how they are directly declared. They also may be optionally given argument names to help contextualize their use:

local coolFunction: (part: BasePart) -> (CFrame, Vector3) = function (part)
    -- `part` is inferred as [BasePart]
    return part.CFrame, part.Size
end

Nullable Functions

If you want to define a function as nullable, you can put a question mark next to the return type:

local maybeFunc: (Instance, string) -> ()?

if maybeFunc then
    -- `maybeFunc` is now [(Instance, string) -> ()]
    maybeFunc()
end

HOWEVER, there are a few places where you need to be careful with how you define them.

For example: (Instance, string) -> Part? is not a nullable function type, it’s a function that returns a nullable Part. You can fix this by putting parenthesis in the right place:

((Instance, string) -> Part)?

Tupled Arguments and Returns

Functions that take variadic arguments are written as ...T, and are not allowed to have a named argument:

local goodFunc: (format: string, ...any) -> ()? -- valid!
local badFunc: (format: string, args: ...any) -> ()? -- syntax error!

Functions as Arguments

You can define the type of a function argument as a function type annotation:

local function awaitChild(object: Instance, name: string, andThen: (child: Instance) -> ())
    task.spawn(function()
        local child = object:WaitForChild(name)
        andThen(child)
    end)
end

Arrays

Arrays in Luau’s type checker are defined by wrapping a type name in curly braces {}. For example, the return type of Instance:GetChildren() is defined as { Instance }

local objects = workspace:GetChildren() -- objects has type: [{Instance}]

for i, child in objects do
    -- child is [Instance]
    print(child.Name)
end

You can define what kind of values an array expects by annotating their type:

local t: {number} = {}
table.insert(t, 5) -- good!
table.insert(t, "lol") -- invalid!

Dictionaries

Dictionary types are a bit more in-depth. There are two ways you can define dictionary fields, both of which can be used at the same time.

Indexer Type

Array types in Luau are actually just a shorthand for a number dictionary indexer. In practice this means {T} is a shorthand for { [number]: T } which is how dictionary indexers are defined:

local t = {} :: {
    [number]: number
}

table.insert(t, 5) -- good!
table.insert(t, "lol") -- invalid!

t[1] = 2 -- good!
t.lol = 3 -- invalid!
t[Vector3.zero] = 4 -- invalid!

(Note: The typecast operator :: will be explained more later, it’s being used here to make the table declaration look a little cleaner)

You can define the indexer to be any type! For example, here’s a way to map Player objects to their positions:

--!strict
local Players = game:GetService("Players")

local positionMap = {} :: {
    [Player]: Vector3?
}

local function onPlayerRemoving(player: Player)
    -- This assignment is valid because we're removing
    -- the player from the dictionary index.
    positionMap[player] = nil
end

local function updatePositions()
    for i, player in Players:GetPlayers() do
        -- Character is a reference, so its type is [Model?]
        local character = player.Character

        if character then
            local cf = character:GetPivot()
            positionMap[player] = cf.Position
        end
    end
end

Players.PlayerRemoving:Connect(onPlayerRemoving)

Explicit Fields & Type Declarations

Type declarations are a core feature of Luau that I’ve refrained from talking about so far because their best use case is with dictionaries. You can use them to create concisely shaped structures for your tables!

type SimpleType = {
    Number: number,
    String: string,
    Function: (...any) -> (...any),
}

local value: SimpleType = {
    Number = 0,
    String = "lol",
    Function = print,
}

Dictionaries are types in the same way that functions and other primitives are types.
If you want to declare a table that starts nil but will exist later, you can do that!

--!strict

local pendingData = nil :: {
    Player: Player,
    UserId: number,
    Coins: number,
}? -- Question mark is important!

local Players = game:GetService("Players")
local player = assert(Players.LocalPlayer)

local userId = player.UserId
local coins = math.random(1, 1000)

pendingData = {
    Player = assert(player),
    UserId = userId,
    Coins = coins,
}

Here are a few real use-cases:

-- This is an entry in the array returned by `HumanoidDescription:GetAccessories`
type AccessoryInfo = {
    AccessoryType: Enum.AccessoryType,
    AssetId: number,
    IsLayered: boolean,
    Order: number?,
    Puffiness: number?,
}

-- With this, we can iterate over the contents of the table with a type annotation!
local accessories: { AccessoryInfo } = hDesc:GetAccessories(true)

for i, info in accessories do
    -- Inferred type of `info` is [AccessoryInfo]
    print("Got accessory with type", info.AccessoryType, "and AssetId", info.AssetId)

    if info.IsLayered then
        print("\tAccessory is layered! Order is:", assert(info.Order))
    end
end

Metatable Types and Object Oriented-ish Modules!

Metatables can facilitate artificial object-oriented classes, and they are supported in Luau’s type annotations! The syntax for it is a little strange, but the benefits of it outweigh the quirkiness by a long shot.

Here’s a simple Person ModuleScript example to start with:

--!strict

local Person = {}
Person.__index = Person

export type Class = typeof(setmetatable({} :: {
    FirstName: string,
    LastName: string,
}, Person))

function Person.new(firstName: string, lastName: string): Class
    return setmetatable({
        FirstName = firstName,
        LastName = lastName,
    }, Person)
end

function Person.GetFullName(self: Class): string
    return `{self.FirstName} {self.LastName}`
end

return Person

There’s a LOT to unpack here, so here’s all the technical details if you’re interested to know more:

  • Person.__index = Person defines a fallback table to search against when using Person as a metatable.
    • When we create an “instance” of the Person type using Person.new, this will allow us to call person:GetFullName(), because it will look for that function in the Person table!
  • export type means the declared type can be imported into other scripts that require the module.
    • It would be defined something along the lines of…
      • local Person = require(script.Parent.Person)
      • type Person = Person.Class
  • type Class = typeof(setmetatable({} :: { is the beginning of a metatable type declaration!
    • The typeof function behaves differently in Luau’s type declaration syntax. Instead of being executed like a Luau function, Luau will instead evaluate the expression in its parenthesis as a Luau type.
      • This is useful for aliasing types with special behavior that can’t be defined with Luau syntax alone (i.e. concrete types, Roblox instances tied to the DataModel, and the magic type returned by setmetatable)
    • We typecast an empty table as a dictionary containing the fields that are required to be defined when creating an “instance” of the Class type through Person.new
      • This is allowed because of some internal Luau jank. setmetatable’s first argument type is a magic “generic table” type that is allowed to be typecasted into any table type, regardless of its contents. As such, there’s no reason to put any fields into the empty table since it’s better to define the fields with their respective types explicitly.
    • The evaluated return type of setmetatable is a magic type that tags the provided table type with the provided metatable type, and typeof scopes this all back into a type.
  • function Person.GetFullName(self: Class): string
    • In order to use Luau’s : member function syntax sugar, self has to be explicitly annotated with the Class type we’ve created.
      • This resolves ambiguity that Luau has regarding what the type of self is, at the cost of needing to define the function with a . instead of a :.
    • There is an alternate way described in Luau’s official typechecking documentation (see the AccountImpl type) but it requires you to manually define a type for the metatable which I personally don’t think is as ergonomic as this strategy.

With all of that out of the way, now you can require this Person module from another script, import its Class type, and use it in both functions and dictionary types as we see fit!

Here’s a super lazy example of a School using the Person type we created:

--!strict
local Person = require(script.Parent.Person)
type Person = Person.Class

type School = {
    Name: string,
    Principal: Person,
    
    Teachers: { Person },
    Students: { Person },
}

local function addTeacher(school: School, student: Person)
    table.insert(school.Teachers, student)
end

local function addStudent(school: School, student: Person)
    table.insert(school.Students, student)
end

local coolSchool: School = {
    Name = "Hella Cool School",
    Principal = Person.new("John", "Doe"),

    Teachers = {},
    Students = {},    
}

local janeDoe = Person.new("Jane", "Doe")
addStudent(coolSchool, janeDoe)

local coolTeacher = Person.new("Cool", "Teacher")
addTeacher(coolSchool, coolTeacher)

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

local function printSchoolInfo(school: School)
    print("School Name:", school.Name)
    print("Principal:", school.Principal:GetFullName())

    print("Teachers:")
    for i, teacher in school.Teachers do
        print(`\t{teacher:GetFullName()}`)
    end

    print("Students:")
    for i, student in school.Students do
        print(`\t{student:GetFullName()}`)
    end
end

printSchoolInfo(coolSchool)

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

Typecasting

So what was the deal with that :: operator earlier? This is a feature of Luau called typecasting, which allows you to override what the automatically inferred type of a value is.

Typecasts are allowed if the provided type can be converted into the target type. There are a few rules and points to be made of this:

  • All types can be casted into any/unknown/never. Likewise, any/unknown/never can be casted into any type.
    • Do this with caution understanding what you’re trying to do, because this effectively bypasses Luau’s type contracting and puts the responsibility onto you and whoever may maintain your code in the future to ensure it complies.
  • Untyped empty tables can be casted into any indexer table.
    • As soon as Luau gets a contextual clue to what the type is, the type will become inferred and cannot be inferred as another type.
  • Concrete types can be casted up into their base classes (i.e. BasePartPVInstanceInstance), but not the other way around.

Use Cases

Inline Typing

Consider the following untyped definition:

local data = {
    Kills = {},
    Deaths = {},
}

If we want to convert this to type annotations, we could declare the type explicitly like this:

type PlayersData = {
    Kills: {
        [Player]: number
    },
    Deaths: {
        [Player]: number
    },
}

local data: PlayersData = {
    Kills = {},
    Deaths = {},
}

This is fine, but we might only use this PlayersData type annotation once for the data variable, so we could just inline the type instead of defining a type alias for it:

local data: {
    Kills: {
        [Player]: number
    },
    Deaths: {
        [Player]: number
    },
} = {
    Kills = {},
    Deaths = {},
}

As you can see though… this looks a little bit rough. It’s not very hygenic to have a multi-line type inline like this.

This is where typecasting empty tables comes in handy:

local data = {
    Kills = {} :: {
        [Player]: number
    },

    Deaths = {} :: {
        [Player]: number
    },
}

Now we effectively have the same type behavior, but in a more compact and readable structure on-site!

Unsafe Dynamic Access

At the moment, Luau warns when trying to perform dynamic table indexing on concrete types. If you absolutely know what you’re doing is fine, you can use a cast to any to get around this.

For example:

local bodyColors = Instance.new("BodyColors")

for i, bodyPart in Enum.BodyPart:GetEnumItems() do
    (bodyColors :: any)[`{bodyPart.Name}Color3`] = Color3.new(1, 0, 0)
end

That’s all (for now) folks!

This isn’t every aspect of Luau, but it’s a lot of the core stuff I desperately felt was in need of a full proper best-practices tutorial. I’ll definitely update this more in the future if people would like to see more areas covered (such as generics and type unioning).

Feel free to check out Roblox’s official typechecking documentation as well for coverage of additional things I may not have covered here yet: Type checking - Luau

If it’s any help, I also have a few open source projects and modules that are fully --!strict
Feel free to check them out:

If you have any questions or addendums, feel free to reply. I’ll keep an eye on this post so long as there’s interest.

Have fun!

165 Likes

I am not really a developer (more so a retired clothing designer). But I cannot wait to see how fellow small developers use these to create awesome games for us users to enjoy.

Thanks for the sources you created! Hope to see amazing things come out of it!

10 Likes

max you’ve enlightened me godspeed

6 Likes

Thanks for this amazing tutorial, Worth the time.

5 Likes

It is really good, thanks for the tutorial. You explained it very clear. I read this topic carefully. I didn’t understand anything but it isn’t your fault. I’m dumb.

4 Likes

Thank’s Max for taking the time to write this guide. Very helpful

5 Likes

This isn’t a bug moreso a “known limitation”. The only reason the Luau VSCode extension doesn’t have this is because we made the design decision to use the types “seen in the eyes of the autocomplete system” for hover as well, whilst Studio chooses types “seen in the eyes of the diagnostics system”. You will notice that in both Studio and VSCode, you won’t get type errors for DataModel types right now.

For a bit more context, see Types a and Player cannot be compared because they do not have the same metatable · Issue #83 · JohnnyMorganz/luau-lsp · GitHub

5 Likes

Welp, that’s annoying. Hopefully this tutorial is still useful in principle as long as I was correct about autocomplete figuring things out.

6 Likes

Good tutorial. Though anyone who read your guide then your open source would be scared away by the generics, and I myself have never even heard the phrase “type pack” :scream_cat: . P.S I’m all for static typing, but I’m not super sure why there is such a push to add complex static typing to a dynamic language - do you want the autocomplete that badly? I don’t want to have to learn type theory to understand someone else’s code.

2 Likes

Yup, definitely! Just thought it would be helpful to point out the context there :slightly_smiling_face:. Still think types are very important everywhere else

4 Likes

Although I was already implementing type annotations into my daily code, I am excited to see this start to be adopted within the development community. Overall, I really enjoyed reading this tutorial, however, there did not seem to be a further elaboration on the :: operator, which I think would have been helpful.

3 Likes

After reading everything, I must say that it is pretty neat!

I am curious to know whenever you need to use these type annotations and how you can connect between Roblox APIs such as TweenService

2 Likes

I agree, I’ll definitely add a section about its other use cases.

With dictionaries, it basically comes down to my own personal preference. If I’m only going to use a type annotation to declare a variable’s structure once, then I can inline it in two ways:

The explicit way:

local t: {
    [string]: {
        A: number,
        B: boolean,
    }
} = {}

Or the implicit typecasted way (my preference):

local t = {} :: {
    [string]: {
        A: number,
        B: boolean,
    }
}

Either is completely valid.

5 Likes

I just like having auto-complete and order to the chaos of dynamic typing. It’s the same reason TypeScript exists, because JavaScript has the same problem.

2 Likes

Great tutorial!

This thread sparked up an issue I had but threw in the backburner when creating types with variadic types. It’s a very simple issue and it goes as such:

--!strict

type foo<F...> = (F...) -> () --> okay
type goo<G...> = (foo<G...>) -> () --> okay
type bar<B...> = (foo<string, B...>) -> () --> expects one pack argument, but two are specified

Basically, even though a variadic generic is defined for foo, it seems as though the linter doesn’t allow a type and another variadic generic to exist simultaneously. This type of type definition can already be seen in things like OnServerEvent, where a type and a varag exist together.

Would you consider this achievable or is it just a limitation of Luau as of now?

1 Like

I think you can wrap string, B… in parenthesis to make them act as one pack argument.

2 Likes

my typehack lives on! (i might not have been the first person to discover it).

One thing I want to add of note, if you do a generic type and use typeof, the generic params can be used insde that typeof statement.

I haven’t tested this yet but I wonder if a type involving generics and newproxy() coercionhaha funny bug would work for metatables?

1 Like

Bookmarked! This is such a great resource for strict typed luau!

Thanks clone trooper!

4 Likes

Metatables type definitions are the bane of my existence. However, I am pretty intrigued by this particular format by Slietnick (or whoever made it first, I just happened to stumble across this one):

I’ve incorporated this kind of metatable definition into all of my codebase. The unique part about defining metatable types like this is that you’re not forced to use the typeof keyword to define your types. Personally speaking, typeof is very “dirty” in the sense that its’ behavior isn’t always expected. Defining the metatable type with this method removes the use of typeof and thus makes the type “cleaner”.

There is one caveat, however. You must do a little intellisense trickery to actually have it working as intended:

-->> typecasting to unknown then refining it to the class type
local self = (setmetatable({}, Class):: unknown):: ClassType
1 Like

The main issue I have with this strategy is that it doesn’t strictly enforce the contract of the implementation. The author/maintainer of the module has to make sure the manually defined type is synchronized with the implementation itself.

In other words, there’s no warning produced if you deviate from the type description. The only reason I’d see this being reasonable is if there’s a bug going on with generics and metatables necessitating this… which might be the case if it’s related to this bug I reported:

Either way, it’s not safe unless it’s contractually enforced. Casting to unknown breaks this contract.

2 Likes