The main difference between the three commands is the use of the -Wait argument and parentheses
To build on Mathias R. Jessen's helpful comment:
It is primarily the use of Start-Process's -Wait switch that is required:
Without -Wait, Start-Process runs asynchronously.
Without -PassThru, Start-Process produces no output.
While -PassThru makes Start-Process output a System.Diagnostics.Process instance representing the newly launched process, unless -Wait is also present that instance's .ExitCode property has no value yet, because the launched process typically hasn't exited yet.
Additionally, parentheses ((...)) are required too, because Start-Process emits the System.Diagnostics.Process instance representing the newly launched process to the pipeline (as received by ForEach-Object) right away, and then waits for the process to exit. By using (...), the grouping operator, you're forcing a wait for Start-Process itself to exit, at which point the Process' instance .ExitCode property is available, thanks to -Wait.
In general, wrapping a command in (...) forces collecting its output in full, up front - which includes waiting for it to exit - before the results are passed through the pipeline (as opposed to the streaming (one-by-on output) behavior that is the default, which happens while the command is still running).
Therefore, the following works - but see the bottom section for a simpler alternative:
# Note: With (...), you could also pipe the output to ForEach-Object, as in
# your question, but given that there's by definition only *one*
# output object, that is unnecessary.
(
Start-Process -PassThru -Wait -FilePath 'msiexec' -ArgumentList '/i C:\Users\Downloads\Everything-1.4.1.1015.x64.msi -quiet'
).ExitCode
It follows from the above that using two separate statements would work too (given that any statement runs to completion before executing the next):
$process = Start-Process -PassThru -Wait -FilePath 'msiexec' -ArgumentList '/i C:\Users\Downloads\Everything-1.4.1.1015.x64.msi -quiet'
$process.ExitCode
msiexec.exe is unusual in that:
it is a GUI(-subsystem) executable (as opposed to a console(-subsystem) executable), which therefore - even when invoked directly - runs asynchronously.
yet it reports a meaningful process exit code that the caller may be interested in, requiring the caller to wait for its exit (termination) in order to determine this exit code.
As an aside: For invoking console applications, Start-Process is not the right tool in general, except in unusual scenarios - see this answer.
An simpler alternative to using msiexec with Start-Process -PassThru -Wait is to use direct invocation via cmd /c, which ensures both (a) synchronous invocation and (b) that PowerShell reflects msiexec's exit code in its automatic $LASTEXITCODE variable:
cmd /c 'msiexec /i C:\Users\Downloads\Everything-1.4.1.1015.x64.msi -quiet'
$LASTEXITCODE # output misexec's exit code
Note: If the msiexec command line needs to include PowerShell variable values, pass an expandable (double-quoted) string ("...") to cmd /c instead and - as with verbatim (single-quoted) string ('...') strings - use embedded double quoting around embedded arguments, as necessary.