Skip to main content

01 / 16

Guard clauses and assertions

Validate first and return early. Assert only for states correct code cannot reach.

A guard clause checks a precondition and returns before the main work begins. Assertions are a separate tool. They report a programming error, so they do not belong on failures a shipped game has to survive.

Rules

  1. 01It's ideal to use ordinary branches for invalid input, network timing, optional content, and any other failure that can happen during normal gameplay.
  2. 02Finish validation before changing state.
  3. 03Use continue to reject one loop element without nesting the remaining operation.
  4. 04Never place required side effects inside check. When checks are disabled the macro expands to CA_ASSUME(expr), which does not evaluate the expression at all.
  5. 05Assertions are stripped from Test builds as well as Shipping. Both configurations set DO_CHECK from USE_CHECKS_IN_SHIPPING, which defaults to 0.
  6. 06ensure evaluates its expression and returns the result in every configuration, so if (!ensureMsgf(...)) still branches correctly once ensures are disabled.
  7. 07ensure reports once per call site. Use ensureAlways when every occurrence matters, such as a fault inside a loop you are actively diagnosing.
  8. 08Use checkSlow for expensive invariants. It compiles only when DO_GUARD_SLOW is set, which happens in Debug builds.
  9. 09Use checkNoEntry() for a branch that must never execute, such as the default case of a switch that handles every enumerator.
  10. 10Use IsValid() rather than a null check when a UObject pointer may reference an object pending destruction.

Return early when a precondition fails

Check invalid states first so the main work does not end up buried inside nested branches.

WeaponComponent.cppcpp
bool UWeaponComponent::TryFire(const FVector& AimDirection)
{
    if (!IsValid(OwnerCharacter))
    {
        return false;
    }

    if (AmmoCount <= 0 || IsOnCooldown())
    {
        return false;
    }

    const FVector FireDirection = AimDirection.GetSafeNormal();
    if (FireDirection.IsNearlyZero())
    {
        return false;
    }

    SpawnProjectile(FireDirection);
    --AmmoCount;
    StartCooldown();
    return true;
}
Why
You can read the preconditions from top to bottom, then follow the successful path without tracking several levels of indentation. The function does not change state until every check has passed.
Costs
Multiple returns make manual cleanup easy to miss. Keep cleanup in scoped Unreal or C++ types so it happens regardless of which return runs.
Instead
Move shared checks into a named validation function when several call sites follow the same rules. A single exit still makes sense when an external API requires centralized cleanup.

Failure mechanisms

Ordinary branch

The failure is expected during normal gameplay. Runs in every build and handles the failure directly.

CostsThe function or its caller needs a defined recovery path.

check / checkf

Correct code should never reach this state, and execution cannot continue safely. Halts where the bad state is first detected.

CostsCompiled out of Test and Shipping, where it becomes CA_ASSUME(expr) and the expression is never evaluated. Nothing inside it can be required work.

verify / verifyf

The expression must run in every configuration, even though a false result is still a bug. Assigning and testing in one step is the usual case.

CostsOnce checks are disabled the expression runs but the failure is no longer reported, so execution continues past a known-bad state.

ensure / ensureMsgf

The state is wrong but the function can recover. Reports a callstack once, returns false, and lets you branch on the result.

CostsReports only on the first failure at that call site. The following line still has to handle the bad state.

ensureAlways / ensureAlwaysMsgf

You need every occurrence reported, not just the first, such as a fault inside a loop under active investigation.

CostsA failure in a hot path floods the log and slows the build it is running in.

checkSlow / checkfSlow

The invariant is expensive to test and only worth paying for in a Debug build.

CostsRequires DO_GUARD_SLOW, which is enabled in Debug only. Development builds never run it.

checkNoEntry()

A branch must never execute, such as the default case of a switch that already handles every enumerator.

CostsCompiles to nothing once checks are disabled, so any fallback the function needs must be written separately.

Pick assertions by what happens in Shipping

check, verify, and ensure behave differently. Choosing one is more than a matter of how severe the message sounds.

InventoryComponent.cppcpp
void UInventoryComponent::InitializeInventory()
{
    checkf(MaxSlots > 0, TEXT("MaxSlots must be greater than zero"));

    verifyf(LoadStartingInventory(), TEXT("Starting inventory failed to load"));

    if (!ensureMsgf(IsValid(ItemRegistry), TEXT("ItemRegistry is unavailable")))
    {
        DisableInventory();
        return;
    }

    bIsInitialized = true;
}
Why
checkf marks a state that correct code should never reach. verifyf keeps the expression running in Shipping. ensureMsgf reports the fault without stopping execution.
Costs
check normally disappears from Shipping, including any side effect inside the expression. ensure continues, so you still need to handle the bad state.
Instead
Use an ordinary branch for expected player input, network timing, missing optional content, or any failure the shipped game must handle.

Gotcha

A check in Shipping does not evaluate its expression

With checks disabled, check(expr) expands to CA_ASSUME(expr), a static-analysis hint that never runs the code. verify(expr) expands to a plain if and still evaluates. That difference is why verify exists: use it when the call inside the assertion has to happen.

All 16 rules