---
url: /rfcs/syntax.md
description: >-
  The story behind interpolation, compound assignment, if-expressions,
  iteration, and const.
---

# Syntax RFCs

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

## Learning objectives

* You interpolate strings with backticks.
* You update values with compound assignment.
* You pick values with if expressions.
* You loop tables without pairs or ipairs.
* You lock bindings with const.

## Prerequisites

* [Syntax and Control Flow](../luau-basics/syntax-and-control-flow.md)
* [Strings and Libraries](../luau-basics/strings-and-libraries.md)
* [Annotations and Inference](../strict-typing/annotations-and-inference.md)
* [How to read RFC status](./implemented.md)

## String interpolation

Backtick strings embed values directly in player-facing text.

### Original problem

You built messages with string dot format. You matched every verb to every value by hand. A wrong verb lost precision or crashed. Booleans needed tostring first. A stray percent sign broke the call. The [interpolation RFC](https://rfcs.luau.org/syntax-string-interpolation.html) lists each failure.

### Change and motivation

The language gained backtick strings with braces. You write the value where it belongs. The compiler lowers the literal into a format call with the star token. That token stringifies any value, including tables with tostring metamethods.

### Implemented semantics

You open with a backtick and close with a backtick. You wrap each embedded expression in braces. The braces hold any expression. They cannot hold a doubled open brace, so you escape a literal brace with a backslash. Adjacent text stays on one line unless you escape the newline. An embedded expression may span lines.

Unlike the old call in three ways: you never name a verb, you never pre-convert with tostring, and you cannot call through the literal without parentheses.

### Before and after

Before:

```luau
--!strict
local playerName = "Maya"
local coins = 30
print(string.format("Player %s holds %s coins.", playerName, coins))
```

```txt
Player Maya holds 30 coins.
```

You match two verbs to two values. You repeat each name. One slip breaks the line.

After:

```luau
--!strict
local playerName = "Maya"
local coins = 30
print(`Player {playerName} holds {coins} coins.`)
```

```txt
Player Maya holds 30 coins.
```

You name each value once. The braces mark the slots.

### Realistic example

You greet a player with their rank and balance in one line.

```luau
--!strict
local Players = game:GetService("Players")
local player = Players.LocalPlayer
if player ~= nil then
  local rank = player:GetAttribute("Rank")
  local balance = player:GetAttribute("Coins")
  local name = player.DisplayName
  print(`{name} plays as {rank} with {balance} coins.`)
end
```

You read the player. You read two attributes. You read the display name. You print one sentence with three slots. A nil attribute prints as nil, which tells you the attribute is missing.

### Strict implications

Interpolation accepts any value, so the checker rarely complains about the slots. It still checks the embedded expressions. A misspelled local inside braces is an unknown global or nil error. Attributes return Option-like nilable values, so you narrow them before you do math, even though printing them raw is fine.

### Edge cases

Doubled braces are a parse error. You write a literal brace with a backslash. A percent sign inside the literal text stays literal, but the lowering still treats format tokens in the text literally, so you test messages that contain percent signs. Interpolation is unavailable in type annotations, so you keep backticks out of type positions.

### Status

The [string interpolation RFC](https://rfcs.luau.org/syntax-string-interpolation.html) shows **Status**: Implemented. You use it today. You still confirm it in Studio per [how to read status](./implemented.md).

## Compound assignment

It folds read, math, and write into one statement. You use it for counters, totals, and builders.

### Original problem

You repeated long paths on both sides of simple updates. A line like data braced index cost equals itself plus one names the target twice. Long paths invite typos. Repeated index expressions also evaluate twice.

### Change and motivation

The language gained plus equals, minus equals, times equals, divide equals, floor divide equals, percent equals, caret equals, and dot dot equals. Each one evaluates the left side once as a location. Each one applies the operator and stores the result. The [compound assignment RFC](https://rfcs.luau.org/syntax-compound-assignment.html) defines them as statements, not expressions.

### Implemented semantics

You write target op equals value. The target can be a local, an index, or a field path. The compiler evaluates the target location once. It reads the old value, applies the operator with existing metamethods, and writes the result. No new metamethods exist.

Unlike a plain assignment in two ways: the target evaluates once, so a costly index runs once, and the statement produces no value, so you cannot nest it inside another expression.

### Before and after

Before:

```luau
--!strict
local stock = { wood = 4 }
stock.wood = stock.wood + 6
print(stock.wood)
```

```txt
10
```

You name the field twice. Short code hides the cost. Long paths make it worse.

After:

```luau
--!strict
local stock = { wood = 4 }
stock.wood += 6
print(stock.wood)
```

```txt
10
```

You name the field once. The update reads as one step.

### Realistic example

You drain a bridge toll from a player balance and append to a log string.

```luau
--!strict
local balances = { Maya = 120, Theo = 40 }
local log = "tolls:"
balances.Maya -= 20
log ..= " Maya paid 20."
print(balances.Maya)
print(log)
```

```txt
100
tolls: Maya paid 20.
```

You debit Maya by 20. You append a sentence to the log. You print both. The minus equals changes a number. The dot dot equals grows a string.

### Strict implications

The operator must fit the operand types. You cannot add to a string or concatenate onto a number. The checker verifies the combination like the plain operator. The target keeps its type, so adding a number to a number stays a number.

### Edge cases

Compound assignment is a statement. You cannot print it or return it directly. Only one value may sit on each side. Reassigning a const binding with any compound operator is an error. Table paths still trigger index and newindex metamethods exactly as the long form would.

### Status

The [compound assignment RFC](https://rfcs.luau.org/syntax-compound-assignment.html) shows **Status**: Implemented. You use it today. You still confirm it in Studio per [how to read status](./implemented.md).

## If expressions

They pick one of two values. You use them for labels, defaults, and config choices.

### Original problem

You faked ternaries with and or chains. The chain returns the fallback whenever the middle value is false or nil, even when the condition holds. That bug hides in plain sight. If statements work but force a mutable local and several lines for one choice.

### Change and motivation

The language gained an expression form shaped like the statement but without end. You write if condition then value else other. Else is mandatory. Elseif chains are allowed. The [if expression RFC](https://rfcs.luau.org/syntax-if-expression.html) keeps short circuit behavior, so only the chosen branch evaluates.

### Implemented semantics

You write the whole choice as one expression. The condition follows truthiness, where only nil and false count as false. The else branch is required, which removes the dangling else trap. The result type is the union of the branches.

Unlike and or chains in one way, it returns the middle value even when that value is false or nil. Unlike the statement in two ways, it yields a value and needs no end.

### Before and after

Before:

```luau
--!strict
local enabled = false
local picked = enabled and enabled or true
print(picked)
```

```txt
true
```

You wanted the flag itself, which is false. The chain replaced it with true. That is the classic bug.

After:

```luau
--!strict
local enabled = false
local picked = if enabled then enabled else true
print(picked)
```

```txt
false
```

The expression returns the then branch untouched. False survives.

### Realistic example

You choose a spawn area and a welcome line from a player count.

```luau
--!strict
local waiting = 9
local zone = if waiting >= 8 then "arena" elseif waiting >= 4 then "lobby" else "tutorial"
local line = if zone == "arena" then `Match starts for {waiting} players.` else "Keep waiting."
print(zone)
print(line)
```

```txt
arena
Match starts for 9 players.
```

You classify the count with an elseif chain. You build a message with interpolation in one branch. You print both. Each branch evaluates only when chosen.

### Strict implications

Both branches must produce values the context accepts. When you annotate the local, each branch must fit the annotation. The checker refines the condition normally, so a typeof test in the condition narrows each branch. You still test booleans by truthiness and keep explicit nil checks for instances.

### Edge cases

Else is mandatory. A missing else is a parse error, not a nil default. Long chains belong on multiple lines with leading then and else past one line, and become statements past three. Autocomplete may insert an end after then out of habit, which you delete.

### Status

The [if expression RFC](https://rfcs.luau.org/syntax-if-expression.html) shows **Status**: Implemented. You use it today. You still confirm it in Studio per [how to read status](./implemented.md).

## Generalized iteration

It loops tables directly. You use it for inventories, rosters, and configs.

### Original problem

You memorized three tools for one job. Arrays needed ipairs. Dictionaries needed pairs. Custom containers needed a method call. A bare for key comma value in table failed with a confusing call error. Teaching and reading loops cost more than the loops themselves.

### Change and motivation

The language lets you write for key comma value in object directly. Plain tables iterate array entries first in order, then the rest in unspecified order. Objects with an iter metamethod customize the traversal. The [generalized iteration RFC](https://rfcs.luau.org/generalized-iteration.html) keeps pairs and ipairs working for code that names them.

### Implemented semantics

You write the loop over the table itself. The compiler expands it through the standard iterator protocol. When the value carries an iter metamethod, that method supplies the generator, state, and start index. When a table defines both call and iter, iter wins for the direct form.

Unlike pairs, array entries come first in order up to the first gap. Unlike ipairs, traversal continues past the first gap through remaining keys.

### Before and after

Before:

```luau
--!strict
local medals = { "bronze", "silver", "gold" }
for index, name in ipairs(medals) do
  print(index)
  print(name)
end
```

```txt
1
bronze
2
silver
3
gold
```

You name the tool before the table. The wrong tool changes the order or stops early.

After:

```luau
--!strict
local medals = { "bronze", "silver", "gold" }
for index, name in medals do
  print(index)
  print(name)
end
```

```txt
1
bronze
2
silver
3
gold
```

You name only the table. The traversal matches the guarantee.

### Realistic example

You pay every member of a crew table and count the payouts.

```luau
--!strict
local crew = { Maya = 120, Theo = 40 }
local paid = 0
for name, balance in crew do
  crew[name] = balance + 10
  paid += 1
  print(`{name} now holds {crew[name]} coins.`)
end
print(paid)
```

```txt
Maya now holds 130 coins.
Theo now holds 50 coins.
2
```

You visit each member. You add 10 to their balance through the key. You bump the counter with compound assignment. The dictionary order is unspecified, so your logic never depends on who prints first.

### Strict implications

The checker infers the key and value types from the table type. You annotate the table when it starts empty so the loop variables get real types. You never add fields mid-loop and expect the checker to follow, because the inferred traversal type comes from the entry type.

### Edge cases

Mutating keys during traversal is unsafe. You collect changes and apply them after the loop when the change affects keys. A table with an iter metamethod hides its raw fields behind that method, so you call pairs explicitly when you truly want raw fields. The loop still uses the first variable as the resume index under the hood.

### Status

The [generalized iteration RFC](https://rfcs.luau.org/generalized-iteration.html) shows **Status**: Implemented. You use it today. You still confirm it in Studio per [how to read status](./implemented.md).

## Const bindings

They lock a name to one value. You use them for tuning numbers, service handles, and module tables.

### Original problem

Every local could be reassigned anywhere later. A reader could never trust that a tuning name still held its first value. Accidental rebinding inside long functions or closures caused silent behavior changes. Exported values had the same weakness on their way out of modules.

### Change and motivation

The language gained a const declaration that works anywhere local works. The binding cannot be reassigned after initialization. The value itself stays mutable unless you freeze it. The [const RFC](https://rfcs.luau.org/const-keyword.html) requires initialization at declaration and allows shadowing with a fresh binding.

### Implemented semantics

You write const name equals value, with an optional annotation. Multi assignment works. Const function declarations work. The lock covers every write form, including compound assignment. Captures keep the lock, so an inner function cannot assign the outer const either.

Unlike a plain local, reassignment is an error. Unlike a frozen table, the table contents behind a const binding still mutate unless you freeze them.

### Before and after

Before:

```luau
--!strict
local maxPlayers = 8
maxPlayers = 12
print(maxPlayers)
```

```txt
12
```

Nothing stops the second line. A later reader assumes 8 and finds 12.

After:

```luau
--!strict
const maxPlayers = 8
print(maxPlayers)
```

```txt
8
```

The binding locks at 8. A later assignment would fail instead of silently changing the tuning.

### Realistic example

You lock service handles and a payout table for a reward granter.

```luau
--!strict
local Players = game:GetService("Players")
const baseReward = 25
const bonusByRank = { rookie = 0, veteran = 10 }
local function rewardFor(rank: string): number
  if rank == "veteran" then
    return baseReward + bonusByRank.veteran
  end
  return baseReward
end
print(rewardFor("veteran"))
```

```txt
35
```

You bind services once. You lock the base reward. You lock the bonus map. The function reads both without changing them. The veteran total prints as 35.

### Strict implications

Const bindings give the checker stable references. Narrowing and inference treat the name as fixed, which sharpens autocomplete. You still annotate function boundaries. You freeze the table when contents must stay fixed, because const alone never freezes contents.

### Edge cases

A bare const without a value is an error. You always initialize at declaration. Shadowing in an inner scope creates a new binding and is legal, which can surprise readers, so you pick a fresh name instead. Table fields behind a const binding still mutate, so you pair const with table dot freeze for true configuration constants.

### Status

The [const RFC](https://rfcs.luau.org/const-keyword.html) shows **Status**: Implemented. You use it today. You still confirm it in Studio per [how to read status](./implemented.md).

## Common mistakes

Wrong: you keep pre-converting values for interpolation.

```luau
--!strict
local coins = 30
print(`Maya holds {tostring(coins)} coins.`)
```

The call works but wastes effort. The star token already stringifies. Fix it by placing the value directly in braces.

Wrong: you nest a compound assignment inside an expression.

```luau
--!strict
local total = 4
local doubled = total * 2
print(doubled)
```

You wanted print of an inline update. Compound assignment returns nothing. Fix it by updating on its own line before you use the value.

Wrong: you fake a choice with and or and lose false.

```luau
--!strict
local flag = false
local out = if flag then flag else true
print(out)
```

This fixed version is correct and prints false. The mistake is the older and or shape, which prints true. Fix it by reaching for the if expression first.

Wrong: you iterate with the wrong classic tool out of habit.

```luau
--!strict
local roster = { Maya = 1, Theo = 2 }
for name, seat in roster do
  print(name)
  print(seat)
end
```

This direct form is correct. The mistake would be forcing pairs around a custom iterable that defines iter. Fix it by using the direct form unless you need raw fields.

Wrong: you treat const as deep freeze.

```luau
--!strict
const tuning = { reward = 25 }
tuning.reward = 50
print(tuning.reward)
```

This prints 50 because the binding lock never freezes contents. Fix it by freezing tables whose contents must stay fixed.

## Exercises

1. Build a shop line with two interpolated values.
2. Replace three long-form updates with compound assignments.
3. Convert an and or chain into an if expression and prove false survives.
4. Rewrite a pairs loop in direct form.
5. Lock three tuning values with const and freeze one table.

::: details Solution 1
Run this pure snippet in the Command Bar.

```luau
--!strict
local item = "sword"
local price = 150
print(`The {item} costs {price} coins.`)
```

```txt
The sword costs 150 coins.
```

You place two values in braces. No verbs appear. The sentence reads naturally.
:::

::: details Solution 2
Run this pure snippet in the Command Bar.

```luau
--!strict
local totals = { coins = 10, gems = 2 }
totals.coins += 5
totals.gems *= 3
totals.coins -= 1
print(totals.coins)
print(totals.gems)
```

```txt
14
6
```

You update each field once by name. Each line is one step.
:::

::: details Solution 3
Run this pure snippet in the Command Bar.

```luau
--!strict
local setting: boolean? = false
local kept = if setting ~= nil then setting else true
print(kept)
```

```txt
false
```

You branch on an explicit nil test. The then branch keeps false. The and or shape would print true here.
:::

::: details Solution 4
Run this pure snippet in the Command Bar.

```luau
--!strict
local queue = { "Maya", "Theo" }
for seat, name in queue do
  print(seat)
  print(name)
end
```

```txt
1
Maya
2
Theo
```

You loop the table directly. Array entries arrive in order. No helper names the tool.
:::

::: details Solution 5
Run this pure snippet in the Command Bar.

```luau
--!strict
const maxParty = 4
const baseXp = 100
const rewards = table.freeze({ coins = 25 })
print(maxParty)
print(baseXp)
print(rewards.coins)
```

```txt
4
100
25
```

You lock two numbers with const. You lock and freeze the table. Reassignment and mutation both fail now.
:::

## Summary

* Backticks embed values. The star token stringifies them.
* Compound assignments are statements. They evaluate the target once.
* If expressions pick values. Else is mandatory and false survives.
* Direct for loops traverse tables. Iter customizes objects.
* Const locks bindings. Freeze locks contents.
* Next: [Generics and Packs](./generics-and-packs.md).
