Media Query Decorator

TypeScript supports decorators which are very convenient for adding complex functionality to classes in a declarative way.

Recently I was doing some work that involved media queries and it struck me that a decorator would be a particularly useful pattern for associating a media query with a class property. @media(...) even looks like a CSS media query.

My initial implementation was rather simple but effective:

const media = (query) => (target, property) => {
  const mq = matchMedia(query);
  Object.defineProperty(target, property, {
    get() {
      return mq.matches;
    },
  });
};

this works great for individual media queries:

class Example {
  @media("(max-width: 767px)")
  small: boolean;

  @media("(min-width: 768px)")
  large: boolean;
}

I then decided it would be more effective to let the @media decorators chain together similar to nested media queries in CSS.

My first attempt can't support this because the defined properties aren't configurable, so after the first media decorator, the second would throw an error.

This can be fixed by adding the configurable property to the options in defineProperty. Since this represents a property, it also makes sense that the property would be enumerable, so I added enumerable as well.

const media = (query) => (target, property) => {
  const mq = matchMedia(query);
  Object.defineProperty(target, property, {
    configurable: true,
    enumerable: true,
    get() {
      return mq.matches;
    },
  });
};

While this is better, the second @media decorator will override and ignore the previous one.

This can be solved by getting the existing property descriptor if one exists, and chaining off of it:

const media = (query) => (target, property) => {
  const mq = matchMedia(query);

  const descriptor = Object.getOwnPropertyDescriptor(target, property);
  if (!descriptor) {
    Object.defineProperty(target, property, {
      configurable: true,
      enumerable: true,
      get() {
        return mq.matches;
      },
    });
  } else if (!descriptor?.configurable) {
    throw new Error(
      "@media cannot override properties that are not configurable"
    );
  } else {
    Object.defineProperty(target, property, {
      configurable: true,
      enumerable: true,
      get() {
        return descriptor.get.call(this) && mq.matches;
      },
    });
  }
};

By checking the existing property descriptors we can now chain as many @media decorators as we want.

class Example {
  @media("(max-width: 767px)")
  small: boolean;

  @media("(min-width: 768px)")
  @media("(max-width: 1023px)")
  medium: boolean;

  @media("(min-width: 1024px)")
  large: boolean;
}

It also wouldn't take a lot of work to be able to remove the extra parenthesis.

Something like

const mq = matchMedia(
  /\(/.test(query) || /all|print|screen/i.test(query) ? query : `(${query})`
);

would probably be sufficient.

And because we're using matchMedia we can check more than just screen sizes:

class Example {
  @media("prefers-reduced-motion")
  animationDisabled: boolean;
}

I really like how the code looks with this approach.


I didn't bother to include proper types within this post. I leave that as an exercise for the reader.