---
url: /strict-typing/annotations-and-inference.md
description: >-
  Label types where it pays, let inference do the rest, and shape tables
  precisely.
---

# Annotations and Inference

*Written: {{ $frontmatter.date }}*

## Learning objectives

* You annotate locals, params, and returns with precise types.
* You read what the checker infers when you omit an annotation.
* You use primitives and Engine types as annotations.
* You shape tables and function values with type syntax.
* You name reusable shapes with aliases and share them with export type.
* You leave trivial locals unannotated.

## Prerequisites

* [Functions Advanced](../luau-basics/functions-advanced.md)
* [Tables in Depth](../luau-basics/tables-in-depth.md)
* [Modules and Require](../luau-basics/modules-and-require.md)

## You annotate the edge, inference fills the middle

You tell the checker what crosses a boundary. The checker figures out the rest inside. Signatures describe the boundary and bodies stay readable.

A colon after a name sets its type. You can annotate a local, a param, and a return. The [type checking](https://create.roblox.com/docs/en-us/luau/type-checking) guide shows this colon form. The [syntax tour](https://luau.org/syntax/) shows the same form for locals, params, returns, and aliases.

An annotation does not change runtime behavior. It only changes what the checker accepts. A wrong annotation rejects good runs. A missing annotation accepts bad runs in loose modes.

Annotate public functions and module boundaries. Let the checker infer short-lived locals. This puts checks where misuse happens and keeps bodies short.

Put this Script in ServerScriptService.

```luau
--!strict
local coins: number = 40
local label: string = "coins"
local active: boolean = true
local empty: nil = nil
```

The checker accepts this block. You infer `coins` as `number`, `label` as `string`, `active` as `boolean`, and `empty` as `nil`. Each value matches its annotation.

Put this Script in ServerScriptService.

```luau
--!strict
local function addCoins(current: number, gained: number): number
	return current + gained
end
local total = addCoins(10, 5)
```

The checker accepts this block. You infer `total` as `number` from the declared return. Both params and the return are annotated. The caller passes two numbers.

Put this Script in ServerScriptService.

```luau
--!strict
local function addCoins(current: number, gained: number): number
	return current + gained
end
local total = addCoins(10, "5")
```

The checker rejects this block. It reports that a string cannot convert to number. You infer nothing usable for `total` on the bad line. The meaning matches the documented example where a string cannot convert into a number in [type checking](https://create.roblox.com/docs/en-us/luau/type-checking). Fix the call by passing a number.

## Primitives and Engine types name what you already use

A primitive names a small value. An Engine type names a Roblox value. You use the same colon in both cases.

The core primitives are `any`, `nil`, `boolean`, `number`, `string`, and `thread` as listed in the [syntax tour](https://luau.org/syntax/). Roblox adds Engine types for Instances, values, and enums. The [type checking](https://create.roblox.com/docs/en-us/luau/type-checking) guide shows `Part`, `BrickColor`, and `Enum.Material` in exactly this role.

`number` covers all Roblox numbers. There is no separate integer or float annotation. `thread` names a coroutine, not a Task thread you schedule. Engine types name the value shape, not the asset or service that made it.

Pick the narrowest true type. You write `number` for counts and `string` for names. You write `Part` when you need a part, not a generic Instance.

Put this Script in Workspace.

```luau
--!strict
local platform: Part = workspace:WaitForChild("Platform") :: Part
local paint: BrickColor = platform.BrickColor
local ground: Enum.Material = platform.Material
```

The checker accepts this block. You infer `platform` as `Part`, `paint` as `BrickColor`, and `ground` as `Enum.Material`. The cast on the first line narrows the looked-up Instance to `Part`. Each annotation matches the value you store.

Put this ModuleScript in ReplicatedStorage.

```luau
--!strict
export type ToolKind = "Sword" | "Bow"
local function kindName(kind: ToolKind): string
	if kind == "Sword" then
		return "blade"
	end
	return "string"
end
local picked = kindName("Bow")
```

The checker accepts this block. You infer `picked` as `string`. `ToolKind` is a union of two string singletons. Only those two spellings pass. A call with `"Hammer"` errors because no union member matches.

## Table shapes and function types describe structure

A table type lists the fields you promise. A function type lists the inputs and the output. The checker compares shapes, not names.

A list of numbers is `{number}`. A map from string to number is `{[string]: number}`. A record lists each field with a colon. A function maps inputs to output with `->`. The [type checking](https://create.roblox.com/docs/en-us/luau/type-checking) guide shows list, map, record, and function forms. The [luau.org types](https://luau.org/types) intro shows the same structural view.

A table annotation seals the shape for later growth. Later chapters cover sealed growth in detail. A function type with the wrong arity rejects correct-looking calls. You must match both count and type.

Write small records with required fields. Write function types that match how you call them. Reuse those shapes instead of repeating them.

Put this ModuleScript in ReplicatedStorage.

```luau
--!strict
export type Stats = {
	coins: number,
	name: string,
}
local function bonus(stats: Stats): number
	return stats.coins * 2
end
local got = bonus({ coins = 5, name = "Mara" })
```

The checker accepts this block. You infer `got` as `number`. `stats` must carry both `coins` and `name`. The literal at the call matches both fields. A literal missing `name` errors because a required prop is absent.

Put this Script in ServerScriptService.

```luau
--!strict
local scores: { number } = { 10, 20, 30 }
local byName: { [string]: number } = {
	Mara = 12,
	Quill = 7,
}
local apply: (number, number) -> number = function(a, b)
	return a + b
end
local done = apply(2, 3)
```

This block checks clean. You infer `scores` as a list of numbers, `byName` as a string-to-number map, `apply` as a two-number function, and `done` as `number`. Each annotation matches its value. A string inside `scores` would error. A boolean value inside `byName` would error.

Put this Script in ServerScriptService.

```luau
--!strict
local function splitName(full: string): (string, string)
	local cut = string.find(full, " ")
	if cut == nil then
		return full, ""
	end
	return string.sub(full, 1, cut - 1), string.sub(full, cut + 1)
end
local first, last = splitName("Ada Quill")
```

This block checks clean. You infer `first` as `string` and `last` as `string`. Parentheses around the return list declare two results. Both return paths send two strings. A path sending one value or a number errors because arity and type must match.

## Aliases name ideas, export shares them

Aliases give long or shared shapes a short name in one place.

`type` defines a local alias and `export type` shares one. A local alias lives in one file. An exported alias can be read through the requiring file's module value, as shown in [type checking](https://create.roblox.com/docs/en-us/luau/type-checking) and the [syntax tour](https://luau.org/syntax/). You write `type Name = ...` for local use. You write `export type Name = ...` for cross-module use.

A nonexported alias is invisible outside its file. An exported alias still needs a `require` on the other side. Renaming the module local does not rename the type access path.

Define item, result, and config shapes once in a shared ModuleScript. Require that module wherever the shape appears. Callers stay in sync when the shape changes.

Put this ModuleScript in ReplicatedStorage with the name GrantTypes.

```luau
--!strict
export type GrantResult = {
	ok: boolean,
	grantedCount: number,
}
local function makeResult(count: number): GrantResult
	return { ok = true, grantedCount = count }
end
local made: GrantResult = makeResult(3)
```

This block checks clean. You infer `made` as `GrantResult`. The alias names a two-field record. The maker returns that exact shape. Consumers read `made.ok` as `boolean` and `made.grantedCount` as `number`.

Put this Script in ServerScriptService. It requires the GrantTypes ModuleScript from ReplicatedStorage.

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local GrantTypes = require(ReplicatedStorage:WaitForChild("GrantTypes"))
local function grantCoins(player: Player, amount: number): GrantTypes.GrantResult
	return { ok = true, grantedCount = amount }
end
```

This block checks clean. You infer `grantCoins` as `(Player, number) -> GrantTypes.GrantResult`. The return annotation points at the shared alias. The body returns both fields with matching types. A missing `grantedCount` would error.

## You skip annotations where inference is obvious

Some locals explain themselves. Do not repeat what the next reader already sees.

Annotate params, returns, module values, and shared shapes. Omit annotations on locals whose right side already fixes the type. The [luau.org types](https://luau.org/types) intro favors inference for locals and annotations at edges for the same reason.

An unannotated local follows its first assignment. A later assignment of a new type errors. That is often what you want. When you need a wider type, annotate up front with a union or optional.

Reviewers read intent at signatures. They skim bodies without annotation noise. Add annotations back when the checker or the next reader needs help.

Put this Script in ServerScriptService.

```luau
--!strict
local retryCount = 0
local shopName = "Sky Shop"
local function price(count: number): number
	return count * 25
end
```

This block checks clean. You infer `retryCount` as `number`, `shopName` as `string`, and `price` as `(number) -> number`. No local annotation is needed. The right sides already fix each type. You keep annotations on the function edge.

## Common mistakes

Wrong: you annotate the container but store the wrong member type.

Put this Script in ServerScriptService.

```luau
--!strict
local scores: { number } = { 10, "late", 30 }
```

This block errors. The checker reports that a string member cannot live in a number list. You fix it by storing only numbers or by widening the annotation to a union when mixed values are truly intended.

```luau
--!strict
local scores: { number } = { 10, 20, 30 }
```

This fix checks clean. You infer `scores` as `{number}`.

Wrong: you forget a required record field.

Put this Script in ServerScriptService.

```luau
--!strict
export type Stats = {
	coins: number,
	name: string,
}
local me: Stats = { coins = 5 }
```

This block errors. The checker reports a missing required prop. You fix it by adding the field.

```luau
--!strict
export type Stats = {
	coins: number,
	name: string,
}
local me: Stats = { coins = 5, name = "Mara" }
```

This fix checks clean. You infer `me` as `Stats`.

Wrong: you annotate every trivial local.

Put this Script in ServerScriptService.

```luau
--!strict
local coins: number = 0
local name: string = "Mara"
local rate: number = 2
local doubled: number = rate * 2
```

This block checks clean but adds noise. Each right side already proves the type. You fix it by keeping annotations at function edges and letting locals infer.

```luau
--!strict
local coins = 0
local name = "Mara"
local rate = 2
local doubled = rate * 2
```

This fix checks clean. You infer `coins` as `number`, `name` as `string`, `rate` as `number`, and `doubled` as `number`.

## Exercises

1. Annotate a two-param number function and call it correctly and incorrectly.
2. Store a `Part`, its `BrickColor`, and its `Enum.Material` in three annotated locals.
3. Define a record alias with two fields and construct one good and one bad value.
4. Write a two-return string parser and bind both results.
5. Export a result alias from one ModuleScript and use it in another script.

::: details Solution 1
Put this Script in ServerScriptService.

```luau
--!strict
local function total(price: number, count: number): number
	return price * count
end
local good = total(4, 3)
```

The checker accepts this block. You infer `good` as `number`. Both args match. Change the call to `total(4, "3")` and the checker rejects the string arg.
:::

::: details Solution 2
Put this Script in Workspace.

```luau
--!strict
local base: Part = workspace:WaitForChild("Base") :: Part
local paint: BrickColor = base.BrickColor
local top: Enum.Material = base.Material
```

This block checks clean. You infer `base` as `Part`, `paint` as `BrickColor`, and `top` as `Enum.Material`. Each annotation names the Engine value you store. A `Model` in `base` would error.
:::

::: details Solution 3
Put this Script in ServerScriptService.

```luau
--!strict
export type Pet = {
	name: string,
	hunger: number,
}
local fed: Pet = { name = "Pip", hunger = 1 }
```

The checker accepts this block. You infer `fed` as `Pet`. Remove `hunger` and the checker reports a missing required prop.
:::

::: details Solution 4
Put this Script in ServerScriptService.

```luau
--!strict
local function cut(full: string): (string, string)
	local at = string.find(full, " ")
	if at == nil then
		return full, ""
	end
	return string.sub(full, 1, at - 1), string.sub(full, at + 1)
end
local a, b = cut("Sky Fox")
```

This block checks clean. You infer `a` as `string` and `b` as `string`. Both paths return two strings. Return one value and arity checking rejects the path.
:::

::: details Solution 5
Put this ModuleScript in ReplicatedStorage with the name LootTypes.

```luau
--!strict
export type Loot = {
	id: string,
	count: number,
}
local first: Loot = { id = "gem", count = 2 }
```

This ModuleScript checks clean. You infer `first` as `Loot`. A second script can require this module and annotate locals as `LootTypes.Loot`.
:::

## Summary

* You annotate params, returns, and shared shapes.
* Primitives name small values. Engine types name Roblox values.
* Table and function syntax names structure.
* Local aliases shorten names. Exported aliases share contracts.
* You omit annotations where the right side already proves the type.
* Next: [Modes](./modes.md).
