Learn why TypeScript throws TS2454 when a variable is read on a path that never assigned it, and how to fix it with initializers, guards and unions.
TS2454 means you read a variable on a code path where TypeScript cannot prove it was ever given a value. You declared a let or var with a type but no initializer, assigned it somewhere conditional, and then used it — and at least one route through the function skips the assignment entirely.
This is TypeScript's definite assignment analysis, part of strictNullChecks (and therefore of strict: true). The compiler walks every path from the declaration to the read. If even one of them arrives without an assignment, the variable would be undefined at that moment, which its declared type does not allow — so it reports the error at the read, not at the declaration.
// The general shape of the error:
let shippingLabel: string
if (order.express) {
shippingLabel = "Express"
}
return shippingLabel
// ~~~~~~~~~~~~~ Error: Variable 'shippingLabel' is used before being assigned.
//
// The path where order.express is false never assigns it.The analysis is deliberately conservative: it only trusts assignments it can see on the path itself. It does not reason about runtime invariants, about whether a loop will actually iterate, or about whether a callback you passed to another function will be called. That conservatism is the whole point — it is also the reason the error sometimes feels wrong when you know the value is there.
elseThe classic case. One branch sets the variable, the implicit empty branch does not.
// ❌ Broken
function summarizeTotals(amounts: number[]): number {
let total: number
if (amounts.length > 0) {
total = amounts.reduce((sum, amount) => sum + amount, 0)
}
return total
// ~~~~~ Error: Variable 'total' is used before being assigned.
}// ✅ Fixed — a const with a ternary has no unassigned path at all
function summarizeTotals(amounts: number[]): number {
const total = amounts.length > 0 ? amounts.reduce((sum, amount) => sum + amount, 0) : 0
return total
}Giving the declaration an initializer — let total = 0 — works too, and is the right call when later code genuinely reassigns it. Prefer the const form when it does not: a variable that is assigned exactly once should say so.
The assignment is right there in the source, so this one surprises people. But forEach takes a function, and TypeScript's analysis does not follow it: nothing in the type of forEach promises that the callback ever runs.
// ❌ Broken
interface User {
id: string
name: string
active: boolean
}
function firstActiveName(users: User[]): string {
let firstActive: User
users.forEach((user) => {
if (user.active) {
firstActive = user
}
})
return firstActive.name
// ~~~~~~~~~~~ Error: Variable 'firstActive' is used before being assigned.
}// ✅ Fixed — let the expression produce the value, then handle the miss
function firstActiveName(users: User[]): string {
const firstActive = users.find((user) => user.active)
if (!firstActive) {
throw new Error("no active user in the list")
}
return firstActive.name
}find returns User | undefined, which turns the invisible "maybe nobody is active" case into a value you have to deal with. If you need to keep the loop, declare the variable as let firstActive: User | undefined and check it afterwards — the union makes the empty case legal instead of hiding it.
try, Read After the catchA catch block that only logs is a path that reaches the code below without the try body having completed.
// ❌ Broken
interface AppConfig {
port: number
host: string
}
function readPort(raw: string): number {
let config: AppConfig
try {
config = JSON.parse(raw) as AppConfig
} catch {
console.error("config is not valid JSON")
}
return config.port
// ~~~~~~ Error: Variable 'config' is used before being assigned.
}// ✅ Fixed — the catch leaves the function, so the read is only reachable on success
function readPort(raw: string): number {
let config: AppConfig
try {
config = JSON.parse(raw) as AppConfig
} catch {
throw new Error("config is not valid JSON")
}
return config.port
}Any exit works — throw, return, or a process.exit() typed as never. What does not work is logging and carrying on, because that is exactly the path where config is missing.
switch With No default Over an Open TypeWhen the switched value is a plain string, number, or anything else with values the cases do not enumerate, the fall-through path is real and the variable stays unassigned on it.
// ❌ Broken
function labelFor(status: string): string {
let label: string
switch (status) {
case "paid":
label = "Paid"
break
case "pending":
label = "Awaiting payment"
break
}
return label
// ~~~~~ Error: Variable 'label' is used before being assigned.
}// ✅ Fixed — a default branch closes the last path
function labelFor(status: string): string {
let label: string
switch (status) {
case "paid":
label = "Paid"
break
case "pending":
label = "Awaiting payment"
break
default:
label = "Unknown"
}
return label
}Narrowing the parameter to a union — status: "paid" | "pending" — fixes it just as well, and is better when those really are the only two states. TypeScript accepts an exhaustive switch over a union without a default, because it can see that no other value exists.
Give the declaration an initializer. If a sensible starting value exists, let total = 0 ends the discussion and removes a whole class of ordering bugs. This is the right fix far more often than people expect.
Turn the branch into an expression and use const. A ternary, an Array.find(), or a small helper that returns the value makes "assigned exactly once on every path" true by construction:
[object Object]A const cannot be read before assignment, so the error becomes impossible rather than merely absent.
Close the missing path. Add the else, add the default, or make the catch throw or return. If no value is sensible on that path, the honest conclusion is usually that the function should not continue there.
Widen to T | undefined when absence is a real outcome. Declaring let firstActive: User | undefined makes the unassigned state part of the type, and the compiler then asks you to handle it at the point of use — which is where the decision belongs. Expect a follow-up TS18048 or TS2322 telling you exactly where that handling is missing.
Reach for ! only with a proof, and never for the flag. total! compiles to nothing, so it converts a compile error into a possible undefined at runtime; keep it for cases like a callback you know runs synchronously at least once, and leave a comment saying why. Turning off strictNullChecks to make TS2454 go away disables null checking for the entire project and hides the crashes this error was pointing at.
TS2454 comes from definite assignment analysis under strictNullChecks. It applies to a let or var that was declared without an initializer and whose type does not include undefined. The compiler checks every path from the declaration to each read; if one of them arrives without an assignment, you get the error. The usual culprits are an if without an else, a switch without a default, a try whose catch falls through, and a for/while body that the compiler cannot prove runs at least once.
Two neighbours are easy to confuse with it. TS2564 is the same idea for class properties under strictPropertyInitialization, and TS2448, "Block-scoped variable used before its declaration", is about ordering — reading a let above the line that declares it. A temporal-dead-zone read reports both codes at once.
You cannot convince the analysis to look inside the callback: it stops at every function boundary, because nothing guarantees the function is called. Restructure so the value comes out of the expression instead of being written into an outer variable.
// ❌ The assignment is invisible to the compiler
let firstActive: User
users.forEach((user) => {
if (user.active) firstActive = user
})
// ✅ The value is the result
const firstActive = users.find((user) => user.active)find, filter, map and reduce cover most of these loops. When you must keep the imperative version, declare let firstActive: User | undefined and check it after the loop. Note the mirror image of this rule: reading the variable inside a nested function is not checked at all, so return () => total compiles even when total has no definite assignment.
Sometimes, but it is the weakest of the fixes. total! is erased at compile time, so if your assumption is wrong the variable is undefined and you get a TypeError instead of a red squiggle. It is reasonable when you hold information the compiler cannot express — a callback documented to run synchronously, an initialization function the framework always calls first — and unreasonable as a quick way to clear a build.
Before using it, check whether a const and a ternary, a default branch, or a T | undefined type would say the same thing while keeping the check. Those take one extra line and keep the compiler on your side the next time someone edits the function.
Browse all TypeScript practice challenges to keep sharpening your type-level skills.
Track your progress through 200+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges