Python-style kwargs in TypeScript

My TypeScript function signatures always start small. There's usually an optional argument or two, but the call sites are easy to reason about:

const greet = (name: string, prefix = "Hello") => `${prefix}, ${name}.`;

greet("Alice"); // "Hello, Alice."
greet("Bob", "!"); // "Hello, Bob!"

But as these functions grow, it's cumbersome to specify the last optional argument while using defaults for the rest:

const greet = (
  name: string,
  prefix = "Hello",
  ending = ".",
  extraNames: string[] | undefined = undefined
) =>
  `${prefix}, ${name}${
    extraNames?.length ? `, ${extraNames.join(", ")}` : ""
  }${ending}`;

greet("Alice", undefined, "!"); // "Hello, Alice!"
greet("Alice", undefined, undefined, ["Bob", "Charlie"]); // "Hello, Alice, Bob, Charlie."

Those floating undefineds are also impossible to reason about unless your development environment can show you the underlying argument names. Not a great reading or debugging experience.

Python's solution is to let callers specify the names of arguments (which is why they're called keyword args, since they're specified by their keyword):

def greet(
    name: str,
    prefix="Hello",
    ending=".",
    extra_names: list[str] | None = None,
):
    ...

greet("Alice", prefix="Howdy") # "Howdy, Alice."
greet("Alice", extra_names=["Bob", "Charlie"]) # "Hello, Alice, Bob, Charlie."

This makes it easy to write clear, concise function calls.

And, while there's not a 1:1 analogue in TypeScript, we can actually get pretty close. All it takes are 3 objects:

const greet = (name: string, {}: {} = {})
//                           ^1  ^2   ^3

They should be filled with:

  1. The kwarg names and any default values.
  2. The type for each kwarg. All types should be marked optional.
  3. Nothing! This stays empty.

Here it is in action:

const greet = (
  name: string,
  {
    prefix = "Hello",
    ending = ".",
    extraNames, // implicitly `undefined`
  }: { prefix?: string; ending?: string; extraNames?: string[] } = {}
) =>
  `${prefix}, ${name}${
    extraNames?.length ? `, ${extraNames.join(", ")}` : ""
  }${ending}`;

greet("Alice"); // "Hello, Alice."
greet("Alice", { ending: "!" }); // "Hello, Alice!"
greet("Alice", { extraNames: ["Bob", "Charlie"] }); // "Hello, Alice, Bob, Charlie."
greet("Alice", { prefix: "Howdy", extraNames: ["Bob", "Charlie"] }); // "Howdy, Alice, Bob, Charlie."

Readability is much improved! We get full type safety on all the args too, so these calls are easy to write and debug.

Any key that receives undefined will use the function's default, so you never have to provide a default in callers:

const greetHowdy = (name: string, ending?: string) =>
  greet(name, { ending, prefix: "Howdy" });

greetHowdy("Alice"); // "Howdy, Alice."
greetHowdy("Alice", "!"); // "Howdy, Alice!"

The only downside I've found is that now, the option keys are part of your function's API. You can change them internally in the function, but changing the key callers use is a breaking change. But, if you're using this in non-user-facing code, the TypeScript LSP can perform renames for you, so updates are painless.


Typed option bags lke this are one of my favorite TypeScript patterns, but I don't feel like I see it often. Give it a try next time you've got a function with more than 1 optional argument!