Appearance
Generics
Written: 2026-10-14
Learning objectives
- You write generic functions that remember member types.
- You let the checker infer type args and instantiate openly only when needed.
- You build generic aliases with defaults.
- You name function-type args for readers and tools.
- You constrain with unions and intersections.
- You ship typed wrappers and skip generics that add noise.
Prerequisites
Generic functions remember what any forgets
A generic function takes many input types but keeps each call precise. The checker remembers the type you passed.
The precise rule is a type param on the function. You write local function first<T>(list: {T}): T? and T stands for the caller type. The same idea powers list and map aliases like List<T> and Map<K, V> in type checking. The dedicated guide defines generic functions and shows inference at calls in generics.
Each extra param must earn its keep. One unused T confuses readers and weakens errors.
One first, one retry, one Result serves numbers, strings, and records. Callers keep full type info without casts.
Put this Script in ServerScriptService.
luau
--!strict
local function first<T>(list: { T }): T?
if #list == 0 then
return nil
end
return list[1]
end
local a = first({ 10, 20 })
local b = first({ "Mara", "Quill" })This block checks clean. You infer a as number? and b as string?. The checker infers T as number on the first call and string on the second. No annotation at the call is needed. An empty list returns nil through the optional.
Inferred calls stay clean, explicit calls pin intent
You let inference pick types. You pin them only when inference picks wrong or too wide.
The precise rule is inference first, <<>> when needed. A call like first({1, 2}) infers T. A call like pick<<number?>>(nil) pins T to number?. Explicit instantiation with <<>> is defined in explicit instantiation and summarized in generics.
Explicit args repeat what readers already see. Overuse hides the values behind type noise. Some calls cannot infer, like a bare nil with no context, so you must pin or annotate the receiver.
You write plain calls by default. You add <<>> for nil seeds, supertype intent, or ambiguous empties.
Put this Script in ServerScriptService.
luau
--!strict
local function wrap<T>(value: T): { value: T }
return { value = value }
end
local inferred = wrap(5)
local pinned = wrap<<number?>>(nil)This block checks clean. You infer inferred as { value: number } and pinned as { value: number? }. The first call infers T as number. The second pins T to number? so nil is accepted as the wrapped empty. Without pinning, a bare nil call would infer too narrowly.
Put this Script in ServerScriptService.
luau
--!strict
local function wrap<T>(value: T): { value: T }
return { value = value }
end
local multi = wrap<<string, number>>( "hi" )This block errors. wrap takes one type param but the call supplies two. You fix it by passing one arg or by defining a two-param generic. The error proves explicit lists must match the declared list.
Generic aliases name containers, defaults shorten use
Lists, maps, results, and options repeat. An alias names the pattern once.
The precise rule is params plus optional defaults. You write type List<T> = { T } and type Map<K, V> = { [K]: V } as in type checking. You add defaults like type Box<T = string> = { value: T } so Box means Box<string>. Defaults for aliases are defined in default type params. Generic functions cannot take defaults generics.
Once one alias param has a default, later params need defaults too. A later param can reuse an earlier one, like type Eq<T, U = T>. You cannot skip this order.
Common cases stay short. Rare cases pass explicit args. Refactors add params with defaults instead of editing every use.
Put this ModuleScript in ReplicatedStorage.
luau
--!strict
export type Box<T = string> = { value: T }
export type Result<T, E = string> =
{ ok: true, value: T }
| { ok: false, err: E }
local short: Box = { value = "gem" }
local num: Box<number> = { value = 4 }This block checks clean. You infer short as Box<string> and num as Box<number>. The default fills string when you omit the arg. An explicit arg overrides it. Both records keep their member types.
Named args document function values
A function type like (number, string) -> string hides which is which. Names reveal intent.
The precise rule is optional names in function types. You write type Greet = (name: string, zone: string) -> string. Names do not change checking. They improve hover text and completions. Named function args are defined in named function args. The syntax tour shows function types in the same family.
Two function types with different arg names but same types still match. Renaming never fixes a type error. It only helps readers.
Callbacks, retry hooks, and event handlers state each input. Reviewers stop guessing order.
Put this Script in ServerScriptService.
luau
--!strict
export type Award = (name: string, count: number) -> string
local function run(award: Award): string
return award("Mara", 2)
end
local got = run(function(name, count)
return name .. tostring(count)
end)This block checks clean. You infer got as string. The alias names both inputs. The anonymous handler matches by type. Hovering award shows the names.
Unions and intersections constrain without new syntax
Sometimes a generic must carry a field or accept only a few shapes. You state that bound with composition.
The precise rule is constrain by use. Luau has no where bound on generics in these docs. You intersect to require shape, like T & { name: string }. You union to allow choices, like T | string. Unions and intersections are defined in type checking and the syntax tour.
A failed intersection reports the missing member, not a clean bound message. A wide union accepts more than you meant when members overlap.
You require name by intersecting a record. You accept a few literals by unioning them. You keep the generic itself reusable.
Put this Script in ServerScriptService.
luau
--!strict
local function nameOf<T>(item: T & { name: string }): string
return item.name
end
local got = nameOf({ name = "Mara", level = 4 })This block checks clean. You infer got as string. The intersection requires name while keeping extra fields. A record without name errors at the call. That missing-field report is the bound working.
Put this Script in ServerScriptService.
luau
--!strict
local function id<T>(value: T | string): string
if typeof(value) == "string" then
return value
end
return "other"
end
local got = id(7)This block checks clean. You infer got as string. The union allows any T plus strings. The branch refines the string case. You use this shape when one member needs special handling.
Typed wrappers pay rent, noise does not
A retry helper, a result type, or a once-guard repeats across features. Generics make one version serve all.
The precise rule is wrap once with params. You define Result<T, E> for fallible work. You define retry<T>(work: () -> T, tries: number): T for repeated work. Callers keep exact success types. Failures carry typed errors.
A generic wrapper around one call site adds a jump without reuse. A generic with a single use and a concrete type is just a longer spelling.
You generify shared helpers and shared records. You leave single-use code concrete. You delete a type param when every call passes the same arg.
Put this ModuleScript in ReplicatedStorage.
luau
--!strict
export type Result<T, E = string> =
{ ok: true, value: T }
| { ok: false, err: E }
local function retry<T>(work: () -> T, tries: number): T
local out = work()
local n = 1
while n < tries do
n += 1
out = work()
end
return out
end
local got = retry(function()
return 7
end, 3)This block checks clean. You infer got as number. retry runs the thunk and returns its exact type. Swap the thunk to return a string and got becomes string with no wrapper change.
Common mistakes
Wrong: you annotate the generic away with any.
Put this Script in ServerScriptService.
luau
--!strict
local function first(list: any): any
return list[1]
end
local got = first({ 1, 2 })This block checks clean but infers got as any. Later misuse passes silently. You fix it with a type param.
luau
--!strict
local function first<T>(list: { T }): T?
if #list == 0 then
return nil
end
return list[1]
end
local got = first({ 1, 2 })This fix checks clean. You infer got as number?. The param preserves the member type.
Wrong: you give a generic function a default.
Put this Script in ServerScriptService.
luau
--!strict
local function wrap<T>(value: T): { value: T }
return { value = value }
endThis block checks clean as written. Add = string to T and it becomes invalid because function generics take no defaults in generics. You fix reuse with an alias default instead.
luau
--!strict
export type Box<T = string> = { value: T }
local one: Box = { value = "hi" }This fix checks clean. You infer one as Box<string>. Defaults live on aliases, not on functions.
Wrong: you generify a single use.
Put this Script in ServerScriptService.
luau
--!strict
local function load<T>(id: T): T
return id
end
local got = load("player_1")This block checks clean but the generic adds nothing over string. You fix it with a concrete signature.
luau
--!strict
local function load(id: string): string
return id
end
local got = load("player_1")This fix checks clean. You infer got as string. Readers see the real contract at once.
Exercises
- Write a generic first-item helper and call it with two member types.
- Pin a nil seed with explicit instantiation.
- Define an alias with a default and use it short and long.
- Name the args of a callback type.
- Build a Result-based loader with a typed retry.
Solution 1
Put this Script in ServerScriptService.
luau
--!strict
local function first<T>(list: { T }): T?
if #list == 0 then
return nil
end
return list[1]
end
local a = first({ 3, 4 })
local b = first({ "a", "b" })This block checks clean. You infer a as number? and b as string?. One body serves both lists without casts.
Solution 2
Put this Script in ServerScriptService.
luau
--!strict
local function wrap<T>(value: T): { value: T }
return { value = value }
end
local seeded = wrap<<string?>>(nil)This block checks clean. You infer seeded as { value: string? }. Explicit instantiation pins the empty to the intended member type.
Solution 3
Put this Script in ServerScriptService.
luau
--!strict
export type Chest<T = number> = { loot: T }
local a: Chest = { loot = 5 }
local b: Chest<string> = { loot = "map" }This block checks clean. You infer a as Chest<number>. The default keeps the common case short. The explicit arg covers the rare case.
Solution 4
Put this Script in ServerScriptService.
luau
--!strict
export type Visit = (player: Player, zone: string) -> ()
local function tour(visit: Visit, guest: Player)
visit(guest, "Dunes")
endThis block checks clean. Names show which arg is who. Checking still compares only the types, not the names.
Solution 5
Put this ModuleScript in ReplicatedStorage.
luau
--!strict
export type Result<T, E = string> =
{ ok: true, value: T }
| { ok: false, err: E }
local function retry<T>(work: () -> T, tries: number): T
local out = work()
local n = 1
while n < tries do
n += 1
out = work()
end
return out
end
local gems = retry(function()
return 9
end, 2)This block checks clean. You infer gems as number. The wrapper stays generic while each call stays precise.
Beyond generics: type functions and native codegen
Two senior tools sit past this chapter. User-defined type functions (type function) compute types at check time for library-grade APIs; start from Type Functions and keep them small, pure, and debug-printed, a failing type function blocks the whole module's checking. Native codegen (--!native / @native, non-recursive) compiles hot server code to machine code per native code generation; apply it only to measured hot functions, never to whole codebases, and re-profile after enabling since codegen changes inlining and compilation cost.
Summary
- Generic functions remember member types across calls.
- You infer by default and pin with
<<>>for nil or supertype intent. - Generic aliases take defaults. Generic functions do not.
- Named function args aid readers without changing checks.
- You constrain with unions and intersections.
- You generify shared wrappers and leave single uses concrete.
- Type functions compute types; native codegen compiles measured-hot code only.
- Next: Subtyping and Compatibility.