dio.la

Dani Guardiola’s blog

Nov 28, 2023November 28th, 2023 · 1 minute read · tweet

Try, return, finally: a curious JavaScript pattern

There's life beyond the return if you're willing to try.

return is the end

One of the first things you'll learn in JavaScript is that the return keyword ends a function's execution.

For instance, the extremely common "early return" pattern relies on this fact.

ts
function function earlyReturn(): "early" | undefinedearlyReturn() { if (const someCondition: booleansomeCondition) return "early"; // no "else" needed function someOtherLogic(): voidsomeOtherLogic();}Open in playground

This behavior has some implications. Consider the following example.

ts
function function doSomething(): "output value"doSomething() { return function getOutputValue(): "output value"getOutputValue(); function someOtherLogic(): voidsomeOtherLogic();}Open in playground

If you do this, someOtherLogic will never be called, because the function exits after the return statement. This is called "dead code", and some tools will even automatically remove it for you.

A common way to work around this is to store the output value to be able to return it afterward.

ts
function function doSomething(): "output value"doSomething() { const const result: "output value"result = function getOutputValue(): "output value"getOutputValue(); function someOtherLogic(): voidsomeOtherLogic(); return const result: "output value"result;}Open in playground

One classic example is a React context consumer which ensures the context is present.

ts
function function useMyContext(): MyContextValueuseMyContext() { const const value: MyContextValue | undefinedvalue = useContext<MyContextValue | undefined>(context: Context<MyContextValue | undefined>): MyContextValue | undefined

Accepts a context object (the value returned from React.createContext) and returns the current context value, as given by the nearest context provider for the given context.

useContext
(const MyContext: Context<MyContextValue | undefined>MyContext);
if (!const value: MyContextValue | undefinedvalue) throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
("MyContext is not available");
return const value: MyContextValuevalue;}
Open in playground

Life beyond return

Now, consider this.

ts
function function doSomething(): "output value"doSomething() { try { return function getOutputValue(): "output value"getOutputValue(); } finally { function someOtherLogic(): voidsomeOtherLogic(); }}Open in playground

Will someOtherLogic be called? Surprisingly, the answer is yes. Specifically, this is what will happen:

  1. The try block is executed.
  2. The getOutputValue() function is called.
  3. The finally block is executed.
  4. The someOtherLogic() function is called.
  5. The output value of the previous getOutputValue() call is returned.

I found out about this after reading the source of Lexical's readEditorState function, where some cleanup logic needs to run before returning the output value.

ts
export function function readEditorState<V>(editorState: EditorState, callbackFn: () => V): VreadEditorState<function (type parameter) V in readEditorState<V>(editorState: EditorState, callbackFn: () => V): VV>( editorState: EditorStateeditorState: class EditorStateEditorState, callbackFn: () => VcallbackFn: () => function (type parameter) V in readEditorState<V>(editorState: EditorState, callbackFn: () => V): VV): function (type parameter) V in readEditorState<V>(editorState: EditorState, callbackFn: () => V): VV { const const previousActiveEditorState: EditorState | nullpreviousActiveEditorState = let activeEditorState: EditorState | nullactiveEditorState; const const previousReadOnlyMode: booleanpreviousReadOnlyMode = let isReadOnlyMode: booleanisReadOnlyMode; const const previousActiveEditor: LexicalEditor | nullpreviousActiveEditor = let activeEditor: LexicalEditor | nullactiveEditor; let activeEditorState: EditorState | nullactiveEditorState = editorState: EditorStateeditorState; let isReadOnlyMode: booleanisReadOnlyMode = true; let activeEditor: LexicalEditor | nullactiveEditor = null; try { return callbackFn: () => VcallbackFn(); } finally { let activeEditorState: EditorState | nullactiveEditorState = const previousActiveEditorState: EditorState | nullpreviousActiveEditorState; let isReadOnlyMode: booleanisReadOnlyMode = const previousReadOnlyMode: booleanpreviousReadOnlyMode; let activeEditor: LexicalEditor | nullactiveEditor = const previousActiveEditor: LexicalEditor | nullpreviousActiveEditor; }}Open in playground

If you're curious, I wrote about this and more in my Lexical state updates article.


Is this a good idea? I don't know. It's clever, definitely, but it can also be a bit surprising.

Bonus: return in finally

Can you guess what will be logged when the following code runs?

ts
function function doSomething(): "try" | "finally"doSomething() { try { return "try"; } finally { return "finally"; }}var console: Consoleconsole.Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
(function doSomething(): "try" | "finally"doSomething());
Open in playground

The answer is "finally". The return in the finally block overrides the return in the try block.

Here's a sandbox if you want to see for yourself. Open the console to see the output.

Stay weird, JavaScript.


PD: here are some related places in the V8 and Chromium codebases.

Thank you @spirobel (on Discord) for sharing these with me!

Keep up with my stuff. Zero spam.