Skip to content

Instantly share code, notes, and snippets.

@Makeshift
Created October 4, 2023 12:53
Show Gist options
  • Save Makeshift/dbe82ec8803aa39cf18b22fccf743b7a to your computer and use it in GitHub Desktop.
Save Makeshift/dbe82ec8803aa39cf18b22fccf743b7a to your computer and use it in GitHub Desktop.
TS or JS object destructuring with default arguments and type hinting

(Note these examples are in pure NodeJS for simplicity, but this works fine in TS too if you set up the types correctly)

In classic JS, when passing an object as a function parameter, you had to destructure it yourself within the function body:

function a(options) {
  const {foo, bar} = options
  console.log(foo, bar)
}

This method works, but unfortunately means that your IDE type-hinting isn't useful when calling this function, and won't tell you what options you have available:

Object destructuring within the parameters of a function allows JS/TS to show better type-hinting when passing parameters, like so:

function a({foo, bar}) {
  console.log(foo, bar)
}

TS/JS also allows you to set default values for parameters, like this:

function a(foo = "foo", bar = "bar") {
  console.log(foo, bar)
}

a("test") // test bar

That's pretty cool, and you can even combine them:

function a({foo = "foo", bar = "bar"}) {
  console.log(foo, bar)
}

a({foo: "test" }) // test bar

Nice. But what if you have a whole bunch of parameters with a bunch of defaults, or if you want to make the defaults come from another object? You might think you can do this, taking advantage of both defaults and object destructuring:

// Doesn't work!
const defaults = { foo: "foo", bar: "bar" }
function a({foo, bar} = defaults) {
  console.log(props.foo, props.bar)
}

Unfortunately, this doesn't work, because we've only technically defined one parameter which has one default. If the user passes a parameter (their options parameter), JS sees the function as having been passed one parameter, and therefore it ignores the defaults var entirely.

a({foo: "test"}) // test undefined

Previously, the answer to the question of "how to do this" was:

const defaults = { foo: "foo", bar: "bar" }
function a({ foo = defaults.foo, bar = defaults.bar }) {
  console.log(foo, bar)
}

Which is fine, but not ideal if you want to expand 'defaults' at any point later, since you need to add it to your function parameters too. Also, it gets quite messy if you need to add a bunch of default options.

Instead, we can take advantage of the fact that earlier parameters can be referred to in the initializers of later parameters, plus the spread syntax to create an additional parameter that, by 'default', does the work for us:

const defaults = { foo: "foo", bar: "bar" }
function a(props = defaults, {foo, bar} = {...defaults, ...props}) {
  console.log(foo, bar)
}
a({ foo: "test"}) // test bar

Nice, now we can define our defaults elsewhere while still getting our object destructuring for nice type hints.

If you prefer to forego the object destructuring, say for example you need to create a proxy function that builds out a template for an options object, it would be tedious to list out all of the possible options the parent class exposes, and your code would break if they added a new option to that interface. It's also possible to solve that using a similar method:

const defaults = { foo: "foo", bar: "bar"}
function a(props = defaults, { ...options } = {...defaults, ...props}) {
  console.log(options)
}
a({foo: "test"}) // { foo: "test", bar: "bar" }

That means we can accept options that we don't even know exist, and our type hinting still works, albeit it does include an additional parameter now:

For a real-world use-case, let's take this example where I've created a TS class that extends a CDK construct in order to proxy default parameters through it:

import { Repository, RepositoryEncryption, RepositoryProps, TagMutability } from "aws-cdk-lib/aws-ecr"
import { Construct } from "constructs";
import { Stack } from "aws-cdk-lib"

/**
 * Accepts the same props as RepositoryProps, but simplifies lifecycleRules
 * and removes the need to specify the repositoryName.
 *
 * @interface IEcrProps
 * @extends {Omit<RepositoryProps, "lifecycleRules" | "repositoryName">}
 */
interface IEcrProps extends Omit<RepositoryProps, "lifecycleRules" | "repositoryName"> {
  maxImageCount?: number;
  fullRepositoryName?: boolean;
}

/**
 * A wrapper around the ECR Repository construct that simplifies the interface, templates
 * the repository name, and provides some sane defaults.
 *
 * @export
 * @extends {Repository}
 */
export default class ECR extends Repository {

  // Having this as a static member means other modules can easily check their parameters object against
  //  the IEcrProps interface without having to import it. We can also use it internally before we even have
  //  access to a `this`.
  /**
   * Some sane but overridable defaults.
   */
  static readonly defaults: IEcrProps = {
    maxImageCount: 250,
    fullRepositoryName: false,
    encryption: RepositoryEncryption.KMS,
    imageScanOnPush: true,
    // If you wanted 'latest' to work, this has to be mutable
    imageTagMutability: TagMutability.MUTABLE,
  }


  // This builds most of the values of the properties object using { ...ECR.defaults, ...options }
  // Then it destructures the properties object it just created to take out 'fullRepositoryName' and 'maxImageCount', leaving the rest in 'props'
  // We can then build the final properties that we'll pass to the parent class by adding repositoryName and lifecycleRules, then overlaying 'props' on top of it
  //  to get our defaults + any additional properties/overrides to the default that the caller added
  // Now, fullProperties matches the type RepositoryProps, and we can even prove it by asking TS to check against it specifically instead of our
  //  custom interface
  /**
   * Creates an instance of ECR.
   * @param {Construct} scope
   * @param {string} name
   * @param {*} [options=ECR.defaults]
   * @memberof ECR
   */
  constructor(scope: Construct, name: string, options = ECR.defaults, { fullRepositoryName, maxImageCount, ...props } = { ...ECR.defaults, ...options}) {
    // Ensures we have a unique ID for the construct and can also be a name
    const id = `${Stack.of(scope).stackName}-${name}`
    const fullProperties: RepositoryProps = {
      // If the user specifies fullRepositoryName, then `name` is the full name, otherwise we fall back to the templated id
      repositoryName: fullRepositoryName ? name : id,
      lifecycleRules: [{
        description: `Retain the last ${maxImageCount} versions`,
        maxImageCount,
      }],
      ...props
    }
    super(scope, id, fullProperties)
  }
}

Sooo, why is defaults a class member? Couldn't it just as easily be a variable in the file? I mean, yeah, but then if I want to define an object in another class to override those defaults, I have to either let TS infer the types or export the interface from this file and import it into the other. Why do that when I've got a perfectly good class doing the work for me? In this case, I tell TS I'm implementing a Partial implementation of typeof ECR.defaults, which becomes an alias to IEcrProps, but is accessible as long as I've just imported the class and will continue to exist on instances of that class. This makes sure that TS warns me if I try to add properties that won't work, and makes it really obvious where the type comes from.

import { RepositoryEncryption } from "aws-cdk-lib/aws-ecr";
import ECR from "./ecr"

const defaultsForTheseRepos: Partial<typeof ECR.defaults> = {
	fullRepositoryName: true,
	encryption: RepositoryEncryption.AES_256,
	imageScanOnPush: false
}

const MyImportedECRRepos = {
	fooRepo: new ECR(context, "foo-repo", defaultsForTheseRepos),
	barRepo: new ECR(context, "bar-repo", defaultsForTheseRepos)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment