TypeScript: Callbacks can use uninitialized variables that aren't defined during callback execution
Bug Report
🔎 Search Terms
Callback, undefined, null-pointer exception
🕗 Version & Regression Information
- This is a crash
⏯ Playground Link
Playground link with relevant code
💻 Code
interface User {
uid: string
}
interface FirestoreTransaction {
read: () => Promise<User>
update: (x: any) => Promise<boolean>
}
type TransactionCallback = (t: FirestoreTransaction) => any
async function runTransaction(callback: TransactionCallback) {
// Placeholder Firestore transaction
const transaction: FirestoreTransaction = {
read: async () => ({uid: 'placeholder'}),
update: async (x: any) => true,
}
return Promise.resolve(await callback(transaction))
}
async function main() {
const user = await runTransaction(async t => {
try {
const {uid} = user
console.log('uid' + uid)
} catch (e) {
console.error('Cannot read user ID', e)
throw e
}
return await t.read()
})
console.log('user' + JSON.stringify(user))
}
main()
🙁 Actual behavior
I have a project that fetches user details from a Firestore database, which uses a callback pattern to define transactional events. In one of these functions I ran into a runtime error because I was trying to access user within the transaction accidentally and received an undefined error.
[ERR]: "Cannot read user ID", Cannot access 'user' before initialization
This in turn caused the rest of the logic to fail and I was surprised by this error. While I had written this a bit hastily, I thought that the type checker in Typescript would certainly catch that this value would be undefined before trying to access. My tsconfig.json has "strictNullChecks": true.
It seems like a callback is allowed to access a variable that is defined outside of the callback. This seems like a poor assumption, particularly when I am using async/await to halt further execution and the definition of these variables. I also notice that Typescript is fine if the variable name is defined after the callback flow entirely.
const user2 = await runTransaction(async t => {
try {
const {uid} = user
console.log('uid' + uid)
} catch (e) {
console.error('Cannot read user ID', e)
throw e
}
return await t.read()
})
const user = {uid: 'placeholder2'}
🙂 Expected behavior
While callbacks may be able to run at any time, the use of await may give a stronger signal that the return variable and later variable names might not actually exist at the execution time of the callback. I would like there to be some extra warning that the variable might not exist. An error, particularly during strictNullChecks would be useful in catching errors of this class going forward.
About this issue
- Original URL
- State: closed
- Created 3 years ago
- Reactions: 3
- Comments: 23 (4 by maintainers)
I proposed this being an error under the
strictNullChecksflag. If this variable was seen as possibly undefined, then you could use the exclamation operator to override that check. Because technically the variable should be considered possibly undefined.@fatcerberus That’s exactly what I’m trying to demonstrate. In
Dispatch.cancel(jobToken),jobTokenshould be handled as Possiblyundefined. All inside the expression following=should handlejobTokenas PossiblyundefinedSee my example above for a counterexample. There are plenty of valid cases where the callback is not called immediately, and TS has no way to know that.
But it’s not, as it has been mentioned several times already…
You can argument in both ways: Why should it be possibly
undefined, when it’s clearly always assigned a value?IMO the proper solution would be #11498 to indicate whether the callback is invoked immediately (meaning it’s always
undefined) or deferred (it’s neverundefined).The compiler could handle as Possibly undefined for the whole expression after
=The point I’m trying to make here is that adding
| undefinedto the type doesn’t actually solve the problem you want to solve because it doesn’t reflect what actually happens at runtime:The error TS would need to produce here would necessarily be one that couldn’t be silenced by simply writing
x!and so would break cases where this pattern is actually valid.The compiler handles
let v: number = $expressionas defined inside the expression. It is not because the expression. It is becausevis anumberand not anumber | undefined. Aimmediatedoes not solve this problem.I did not understand.
jobTokencan beundefined, no? If you know it’s not beundefined, a!would solves:You don’t need refactors the code, etc… Just use a
!Assuming I accept that premise, that wouldn’t be enough because in that case accessing the variable at all is a runtime error. In that case even checking whether it’s undefined or not will throw, and your argument becomes one where TS rejects this pattern outright. This is why we’d need the
immediateannotation.In this example, yes. Simply because
xis not assigned inx.toString(). First, the expression, after the assignmentNo, because the value has not been assigned yet during the access within the callback. In
console.log(y.toString()),yis clearlyundefined. Afterreturn 10,yis defined. Aimmediatenotation described in https://github.com/microsoft/TypeScript/issues/11498 makes it more evident. As described by the Playground example, if declareyasnumber | undefined, the compiler already understand that the value is not assigned within the callback and is assigned after de assignment statement (aftery = doIt(...).Use a Possibly undefined is more consistent than Variable ‘x’ is used before being assigned because the callback can have deferred code. In the following example,
console.log(y.toString())works becauseyis already assigned within thesetTimeoutcallback.I think the idea here is just to prevent the accidental access that the issue describes. I think the compiler handle
yasnumber | undefinedin all code within the assignment expression is simple and sufficient for these scenarios. Again, this is exactly the behaviour when declaresyexplictly asnumber | undefined.