Patterns for conditional logic in TypeScript. This standard covers early returns, if/else for simple conditions, and ts-pattern for multi-branch matching. Ternaries are banned by the linter. Choosing the right construct keeps logic flat, exhaustive, and easy to follow.
Use ts-pattern for conditional logic with 2+ branches. It provides exhaustiveness checking and better readability than switch statements or nested ternaries.
| Scenario | Use | Why |
|---|---|---|
| Single boolean | if/else |
Simpler for true/false |
| Early return/guard | if statement |
Cleaner guard clauses |
| 2+ conditions | ts-pattern |
Exhaustive, readable |
| Type narrowing | ts-pattern |
Type-safe matching |
import { match, P } from 'ts-pattern'
// Match on value
const message = match(status)
.with('pending', () => 'Waiting...')
.with('success', () => 'Done!')
.with('error', () => 'Failed')
.exhaustive()
// Match on object shape
const result = match(event)
.with({ type: 'script', status: 'running' }, () => showProgress())
.with({ type: 'script' }, () => showIdle())
.with({ type: 'task' }, () => showTaskInfo())
.exhaustive()
// Match with wildcards and predicates
const label = match(count)
.with(0, () => 'None')
.with(1, () => 'One')
.with(P.number.gte(2), () => 'Many')
.exhaustive()// Nested ternaries are hard to read
const message =
status === 'pending'
? 'Waiting'
: status === 'success'
? 'Done'
: status === 'error'
? 'Failed'
: 'Unknown'
// Switch without exhaustiveness
switch (status) {
case 'pending':
return 'Waiting'
case 'success':
return 'Done'
// Missing 'error' case - no compiler warning!
}Always use the inferred type from the ts-pattern callback parameter. Never cast to explicit types inside a match arm.
match(event)
.with({ config: P.nonNullable, action: P.string }, (e) => {
// `e` is automatically narrowed - use it directly
console.log(e.config.path, e.action)
})
.otherwise(() => {})match(event)
.with({ config: P.nonNullable, action: P.string }, () => {
const configEvent = event as ConfigEvent // Don't cast
console.log(configEvent.config.path)
})
.otherwise(() => {})Use ts-pattern directly to match on object shape rather than creating intermediate categorization functions.
match(event)
.with({ scripts: P.nonNullable, workspace: P.string }, (e) => handleScripts(e))
.with({ config: P.nonNullable }, (e) => handleConfig(e))
.otherwise(() => handleUnknown())const category = categorizeEvent(event) // Don't pre-categorize
match(category)
.with('scripts', () => handleScripts(event as ScriptsEvent))
.with('config', () => handleConfig(event as ConfigEvent))
.otherwise(() => {})Use .exhaustive() to ensure all cases are handled at compile time. Reserve .otherwise() for genuinely open-ended matches.
type Status = 'pending' | 'success' | 'error'
// Compiler error if a case is missing
match(status)
.with('pending', () => 'Waiting')
.with('success', () => 'Done')
.with('error', () => 'Failed')
.exhaustive()match(status)
.with('pending', () => 'Waiting')
.otherwise(() => 'Unknown') // Hides missing casesUse if/else for simple boolean conditions. Ternaries are not permitted by the linter. Extract the logic into a function to keep bindings const.
function getLabel(isActive: boolean): string {
if (isActive) {
return 'Active'
}
return 'Inactive'
}
const label = getLabel(isActive)// Ternaries are banned by oxlint
const label = isActive ? 'Active' : 'Inactive'Use if statements with early returns for guard clauses that reject invalid state before the main logic.
function processScript(script: Script | null) {
if (!script) return null
if (!script.enabled) return null
// Main logic here
return execute(script)
}function processScript(script: Script | null) {
if (script) {
if (script.enabled) {
return execute(script)
}
}
return null
}- Types -- Discriminated unions for type-safe matching