Apex Switch on SObject
switch on over an SObject expression is the right tool for type-dispatch
inside a heterogeneous collection — a generic trigger handler, a polymorphic
processor, a classifier that runs different validation per object type. It
buys two things over a chain of instanceof / getSObjectType().equals(...)
checks: type-narrowed bindings inside each branch (no cast required) and
a single explicit when else for the unhandled case.
What this skill is NOT. The simpler switch on Integer / String / enum form
is plain Apex syntax — go directly to the Apex Developer Guide. Dynamic field
access (Schema.getGlobalDescribe(), record.get(fieldName)) lives in
apex/dynamic-apex. This skill is specifically about the SObject expression
form and the branch-binding semantics.
Before Starting
- Confirm the dispatch is runtime — if you statically know the type at compile time, skip the switch and just write the typed code.
- Confirm whether
when elseis required: if the switch is non-exhaustive (you only handle some types), you almost always needwhen elsebecause unmatched cases silently fall through to the end of the switch with no side-effect — that's the failure mode that hides bugs longest. - Note that the binding form requires exactly one SObject type per
whenblock. You cannot writewhen Account, Contact a { ... }and expect a typeda; that form is reserved for value-list matching on enums and primitives.
Core Concepts
The two switch-on-SObject forms
// Form 1 — bind a typed variable inside the branch (the whole point of this feature).
switch on record {
when Account a {
// 'a' is typed Account here. No cast, no Schema check.
a.AccountNumber = generateAccountNumber();
}
when Contact c {
c.LastName = c.LastName?.toUpperCase();
}
when null {
// Reached only when 'record' itself is null — safe, no NullPointerException.
return;
}
when else {
// Anything else; the runtime type is something we didn't handle.
// SILENTLY SKIPPING this branch is the #1 latent bug — declare it explicitly.
System.debug('Unhandled SObjectType: ' + record.getSObjectType());
}
}
// Form 2 — match by exact value (rare for SObjects; mostly for primitives).
switch on someInteger {
when 1, 2, 3 { /* multi-value matching only works for primitives + enums */ }
when else { ... }
}
Three rules that bite people
- No fall-through. Each
whenbranch executes and the switch exits. There is nobreakkeyword and no implicit fall-through to the next branch (unlike C / Java's switch). when nullmatches a null expression. It does NOT match a record whose Id is null. It is reached only when the expression itself is null. This makes switch null-safe by design — noNullPointerExceptionfrom the dispatch.Type.forName(...)is NOT a substitute for SObjectType.Type.forName('Account')returnsSystem.Type, which is not whatswitch onaccepts in this form. To dispatch from a string name, look up the SObject prototype:Schema.getGlobalDescribe().get(name).newSObject()and switch on that.
Common Patterns
Pattern A — Polymorphic trigger handler
When to use. A single handler that runs across multiple SObject types
(common in framework code, templates/apex/TriggerHandler.cls).
public class PolyHandler {
public static void handle(List<SObject> records) {
for (SObject record : records) {
switch on record {
when Account a { handleAccount(a); }
when Contact c { handleContact(c); }
when Lead l { handleLead(l); }
when else {
// Programmer error — every type the framework registers must have a branch.
throw new HandlerException('Unhandled type: ' + record.getSObjectType());
}
}
}
}
}
Why not the alternative. A chain of
if (record instanceof Account) { Account a = (Account) record; ... } is
verbose and re-introduces casts that the binding form already handled.
Pattern B — Classifier with explicit unhandled-case
When to use. You handle some types and want all other types to fall through harmlessly with a logged warning.
public static String classify(SObject record) {
switch on record {
when Account a { return a.Type; }
when Contact c { return c.LeadSource; }
when null { return null; }
when else {
// Crucial: without this branch, the method returns the default null
// and the caller has no signal that 'record' was an unrecognized type.
ApplicationLogger.warn('classify: unhandled ' + record.getSObjectType());
return null;
}
}
}
Pattern C — Dispatch from a string type name
When to use. The caller has the type name as a string (config-driven dispatch, custom-metadata-driven router).
public static SObject createByName(String objectApiName) {
Schema.SObjectType t = Schema.getGlobalDescribe().get(objectApiName);
if (t == null) {
throw new IllegalArgumentException('Unknown SObject: ' + objectApiName);
}
SObject prototype = t.newSObject();
switch on prototype {
when Account a { return new Account(Name = 'Default Name'); }
when Contact c { return new Contact(LastName = 'Default Last'); }
when else {
// The string named a real SObject, but this method only constructs a few.
throw new IllegalArgumentException('Unsupported for default-create: ' + objectApiName);
}
}
}
Type.forName(objectApiName) does NOT work as the switch expression here —
it returns System.Type, not an SObject prototype.
Decision Guidance
| Situation | Use this | Reason |
|---|---|---|
| Trigger handler dispatching across 3+ SObject types | switch on record with one branch per type |
Typed bindings, explicit when else, no casts |
| Single-type check ("is this an Account?") | record.getSObjectType() == Account.SObjectType |
Switch overhead not worth it for one check |
| Dispatch from a string SObject API name | Schema.getGlobalDescribe().get(name).newSObject() then switch on the prototype |
Type.forName() returns System.Type, not SObjectType |
| Heterogeneous list with frequent unhandled types | Switch with when else returning early or logging |
Silent-skip is the failure mode without when else |
| Two types share identical handling | Two when branches calling the same private method |
Multi-type-per-when binding is not supported |
Recommended Workflow
- Confirm runtime dispatch. If the type is known at compile time, write typed code; do not introduce a switch.
- List every SObject type the dispatch can see. From the caller, the trigger registration, or the framework contract.
- Choose
when elsesemantics. Either (a) throw — programmer error, the dispatch should be exhaustive — or (b) log + return safely. Never omitwhen elseon a non-exhaustive switch. - Place
when nullbeforewhen elseif the expression can be null. Thewhen nullbranch is the null-safety guarantee for the entire switch. - Verify in test. Write a test class that passes one record of every handled type plus one record of an unhandled type; assert the unhandled-case behavior matches step 3.
Review Checklist
- Switch has either
when elseor every concrete SObject type the caller can pass. -
when nullbranch present if the expression can be null. - No
when elseblock silently returns / does nothing without a comment justifying it. - No use of
Type.forName(...)as the switch expression. - Test class covers one record of every handled type + one unhandled type.
- Each branch's bound variable is used (or removed); unused-binding is a smell that the branch could be just
when Accountwithout binding.
Salesforce-Specific Gotchas
- Silent skip without
when else. A non-exhaustive switch on an unhandled type does nothing — no exception, no warning. Always declarewhen else. (Seereferences/gotchas.md§ 1.) Type.forName('Account')does not produce a switch-able expression. It returnsSystem.Type. You needSchema.getGlobalDescribe().get('Account').newSObject()to get an SObject prototype. (Seereferences/gotchas.md§ 2.)- Multi-type-per-when is not supported.
when Account a, Contact c { ... }does not compile. (Seereferences/gotchas.md§ 3.) when nullmatches the expression being null, not an SObject withId = null. Don't use it for "new record" detection. (Seereferences/gotchas.md§ 4.)- Managed-package custom SObjects need the namespace.
when my_pkg__Custom_Object__c c { ... }— the namespace is part of the type identity. (Seereferences/gotchas.md§ 5.)
Output Artifacts
| Artifact | Description |
|---|---|
| Refactored Apex | Switch-based dispatch replacing instanceof chains, with typed bindings per branch |
| Test class | One record per handled type + one unhandled type; asserts when else behavior |
when else decision rationale |
One sentence per refactor: "throw" or "log + safe-return" |
Related Skills
apex/dynamic-apex— Schema.getGlobalDescribe / dynamic field access; pair with this skill when dispatching from a string API nameapex/trigger-framework— when this dispatch goes inside a generic trigger handler;templates/apex/TriggerHandler.clsis the canonical baseapex/apex-mocking-and-stubs— for the test class that exercises every branch