Engineering

Creating an EmptyObject type in TypeScript

Written By

Monroe Ekilah

Illustration of code snipped for Mercury Engineering blog
Copy Link
Share on Twitter
Share on LinkedIn
Share on Facebook
Linegraph tracking a Mercury account balance
Build the next generation of startup bankingExplore Openings*Mercury is a financial technology company, not a bank. Banking services provided by Choice Financial Group and Evolve Bank & Trust®; Members FDIC.
Copy Link
Share on Twitter
Share on LinkedIn
Share on Facebook

In TypeScript, it is impossible to use the type {} to express "this object should be empty." Depending on your experience with TypeScript, you may have been frustrated by this before, or you might be surprised that this is a problem worth discussing.

In this article, I’ll:

  • Introduce the concept of excess property checks in TypeScript
  • Explain how & why the {} type behaves differently than other types
  • Show some examples where the {} type's behavior may be undesirable
  • Demonstrate one way to create and use a real “empty object” type

Excess property checks

In order to appreciate the problem that the {} type poses, you first need to appreciate excess property checks. Let’s go over a quick example of those.

Normally, when you assign an object directly to a variable with a given type in TypeScript, you benefit from something called “excess property checks.” These checks will warn you if you add any unexpected keys to an object as you define it.

For example, the TypeScript compiler will throw an error in the code below because age is not an expected property of the type Cat:

Copy Code
type Dog = {name: string; age: number}
type Cat = {name: string; livesUsed?: number}

const pet: Cat = {name: 'Fluffy', age: 4}
//                                ~~~                               
// TS2322: Type '{ name: string; age: number; }' is not assignable to type 'Cat'.
// Object literal may only specify known properties, and 'age' does not exist in type 'Cat'.

Though conceptually simple, this kind of compiler error is exactly what TypeScript aims to provide over vanilla JavaScript. TypeScript makes certain classes of mistakes harder to make, and makes certain classes of refactors easier!

The error here — and the inclusion of age in const pet's definition — is likely indicative of one of these issues:

  • age was formerly expected in type Cat but has since been removed
  • We forgot to add age to type Cat
  • const pet was changed from Dog to Cat without changing its value
  • We meant to set livesUsed instead of age, which would otherwise not be caught (since it is optional)

Depending on the code that uses your object pet later, and on which of the problems above applies to the situation, the presence of this extra age may or may not cause a real issue in production. However, in all scenarios, it is an error worth addressing!

The {} type

The {} type in TypeScript is "special" in that it allows any Plain Old Javascript Object (POJO), with any number of excess properties, to be assigned to it — even if you directly assign a non-empty object to it:

Copy Code
const x: {} = {foo: 'bar'} // OK

// Note that inlining the type or declaring it separately makes no difference:
type Empty = {}
const y: Empty = {foo: 'bar'} // OK

…whereas other object types usually reject excess properties, like Cat did above.

This behavior may or may not surprise you, depending on what you expect {} to mean, or how you’ve used it before. Depending on their familiarity with {}, TypeScript developers often use the {} type to mean something like “any POJO”, interchangeably with TypeScript’s object type and/or JavaScript’s Object, or something else.


The difference between object, Object, and {} in TypeScript is worthy of a post on its own, so I won’t get into that here. If you are curious, here is a StackOverflow answer for more on this topic.


For example, you may want to add types to a function that wants to take any POJO. That would be a reasonable thing to want to do, and that does seem to work at first with the {} type in TypeScript:

Copy Code
function logMysteryObject(o: {}) {
  console.log('Object has these keys: ', Object.keys(o))
}

logMysteryObject({foo: 'bar', answer: 42}) // OK

Using {} as logMysteryObject's parameter type is better than:

  • any, because at least the input is known to be an object and not, say, null
  • unknown, because we don’t have to check if it’s an object before calling Object.keys
  • some more specific type, because we really do want to accept any POJO

However, this may confuse you — it turns out {} means a lot more than you might assume:

Copy Code
logMysteryObject('test') // OK
logMysteryObject(42)     // OK
logMysteryObject(false)  // OK

Indeed, it seems like there is at least some value in the behavior of {}. That said, it looks a bit funny, and can be misleading! So, is this just a bug in TypeScript?

According to the TS team, the behavior of {} is intentional. Quoting their response in a GitHub issue to address the first code block:

"The excess property check is not performed when the target is an empty object type since it is rarely the intent to only allow empty objects in that case."

And here is an interesting TS pull request that explains more about why the second code block typechecks. The short story is that {} really means “anything except null and undefined.”

When we might want {} to mean {}

At Mercury, we had a use case where excess properties in supposedly-empty objects led to some reasonable confusion and could have theoretically caused bugs in production. I’ll use that as an example below to justify our pursuit for a true EmptyObject type.

Problematic Example: Generic API requests

When we want to add a new endpoint for our TypeScript frontend to call, we make a new class object that inherits from a parent class. This parent class implements many features of an API call, and looks something like this:

Copy Code
abstract class BackendApiRequest<
  RequestBody,  // JSON object
  ResponseBody, // JSON object
  PathPieces    // variable parts of the URL on the backend
> {
    post(
      requestBody: RequestBody,
      pathPieces: PathPieces
    ): Promise<ResponseBody> {
      // code that calls the backend and returns the response
    }
}

The generics here help us (and the TypeScript compiler) know what shapes to send to the backend and which shapes to expect back.

If a simple instance of a BackendApiRequest — say to fetch a list of team members — wants to say "I have no request body, response body, nor a dynamic URL," it might look like this:

Copy Code
class FetchTeamMembers extends BackendApiRequest<
  {}, // "I have no request body!"
  {}, // "I have no response body!"
  {}  // "I have no dynamic pathPieces!"
>

Without the context of this blog post, it’d be hard to blame someone for implementing FetchTeamMembers this way.

You probably see where this is going, though: the problem is that users of this API call will be able to pass non-empty objects to post without any complaint from TypeScript:

Copy Code
const fetchTeamMembers = new FetchTeamMembers()
fetchTeamMembers
  .post({illegalParam: '123'}, {definitelyAnError: true}) // OK 😢

Part of what makes this confusing is that TypeScript normally provides errors on other similar-looking API calls that are incorrectly made. For example, if we had defined FetchTeamMembers’s generics with object types that were not empty, we’d get errors if we passed empty or incorrectly-populated objects to post.

Luckily, you can't access unknown properties of an object with type {}. That means that code parsing the response body is reasonable when using {} for the ResponseBody generic type:

Copy Code
fetchTeamMembers
	.post({illegalParam: '123'}, {definitelyAnError: true}) // OK 😢
	.then(r => r.doesntExistEither) // Error

Passing extra parameters to an API isn't often something that will cause a crash or other detrimental issue, but these unused parameters:

  1. Look like they are actually doing something intentional at the use site, which is misleading to passersby.
    1. Imagine if, instead of illegalParam: '123', it was something like orderBy: 'age'. That sounds like something you might actually want to do to a list of team members, and if you saw this code without reading the backend, you’d probably assume that an orderBy was happening.
  2. Can get left behind when refactoring an old bit of code. For example, some request params (that have since been removed from the backend) might have been required or allowed before.
  3. Can actually get sent to the server (we pass the request body on without modification, as we must in a generic function like .post() — types don't exist at runtime to check for this problem after all!)
    1. The server may ignore these inputs if it isn’t expecting any, but as in (1a) above, what if orderBy is something supported by the server (unbeknownst to the client types), but 'age' isn’t a valid filter value?

Given the problems above, it really would be useful to have a type that actually meant “this object should be empty.” Since TypeScript doesn’t provide one, we’ll have to make one for ourselves!

Creating our own solution

Luckily, creating a type that describes an actually-empty object isn’t too complicated! With TypeScript’s never type, it is quite simple:

Copy Code
// EmptyObject.ts
const emptySymbol = Symbol('EmptyObject type')
export type EmptyObject = {[emptySymbol]?: never}


// example.ts
import type {EmptyObject} from './EmptyObject'

const test: EmptyObject = {} // OK

…and that’s it!

The reason this works is simple. EmptyObject is no longer equal to {} like Empty was in our earlier attempt. And {} is the only type that TypeScript ignores excess property checks on.

Or, to put it another way, it doesn’t matter that {} is the only valid value that we can assign to EmptyObject in other files (because its only member is of type never and its key is an unexported Symbol). What matters is that the two types {} and EmptyObject are different in TypeScript’s eyes!


Using a Symbol that we don’t export as a key means that end-users can’t accidentally or purposely define a value with that key because Symbols are guaranteed to be unique in JavaScript.

If we instead defined EmptyObject as {dontDefineThis?: never}, end-users would technically be allowed to assign undefined to dontDefineThis (thus producing a non-empty EmptyObject).

The only way to avoid that is a relatively new compiler flag, --exactOptionalPropertyTypes, which disallows assigning undefined to optional keys. However, enabling this flag in a large codebase could prove difficult or impractical.


With this type, all of the previous examples will produce errors now, like we wanted them to:

Copy Code
const x: EmptyObject = {foo: 'bar'} // Error 🎉

class FetchTeamMembers extends BackendApiRequest<
  EmptyObject, // "I have no request body!"
  EmptyObject, // "I have no response body!"
  EmptyObject  // "I have no dynamic pathPieces!"
>

const fetchTeamMembers = new FetchTeamMembers()
fetchTeamMembers
  .post(
    {illegalParam: '123'}, // Error 🎉
    {definitelyAnError: true} // Error 🎉
  )
  .then(r => r.doesntExistEither) // (still errors, like before)

All of the above produce error messages like this:

Copy Code
TS2322: Type '{ foo: string; }' is not assignable to type 'EmptyObject'.
Object literal may only specify known properties, and 'foo' does not exist in type 'EmptyObject'.

The one big benefit of this type EmptyObject is that you are able to still pass the literal value {} to functions/variables that ask for EmptyObject.

There are other possible solutions to the EmptyObject problem that would require you to import and reuse a specific object as the value in an EmptyObject position. For example, this alternate solution, which uses a Symbol to guarantee the object used for type AltEmptyObject is always the same const emptyObj:

Copy Code
// empty_obj.ts
const secretSymbol = Symbol('empty obj')
export type AltEmptyObject = {secretKey: typeof secretSymbol}

// You must use this exact value when asked for `AltEmptyObject`.
// Because `secretSymbol` is not exported, it is impossible to
// construct a valid object of type `AltEmptyObject` elsewhere.
export const emptyObj: AltEmptyObject = {secretKey: secretSymbol}


// later, in your codebase
// some_file.ts
import {AltEmptyObject, emptyObj} from './empty_obj'

class AltFetchTeamMembers extends BackendApiRequest<
  AltEmptyObject, AltEmptyObject, AltEmptyObject
>
const altFetchTeamMembers = new AltFetchTeamMembers()

altFetchTeamMembers.post(emptyObj, emptyObj) // OK
altFetchTeamMembers.post({}, {}) // error, annoyingly
altFetchTeamMembers
  .post({illegalParam: '123'}, {definitelyAnError: true}) // error, as before

With the import {emptyObj} requirement, the inability to use the value {}, and the fact that the key secretKey is actually present in the object at runtime, AltEmptyObject is not as convenient or clean as EmptyObject.

Limitations

Throughout this post, we’ve shown how excess property checks are quite useful in general, and why having them for empty object types would be helpful sometimes.

However, it is worth mentioning that excess property checks don’t always come to your rescue as you might expect. TypeScript allows excess properties to end up on an object of a given type if you don't directly assign a “larger” object to a narrower type:

Copy Code
type A = {foo: string}
let a: A = {foo: 'bar', other: 123} // Error, as desired

const looseA = {foo: 'bar', other: 123} // note, no type declaration here
a = looseA // No error, for better or worse

Generally, this is known as “structural typing” or “duck typing.” In structurally-typed systems, an object (like looseA) that at least conforms to the desired shape (A) is allowed where the desired shape is expected (a), even if it has extra properties. If you want to learn more, the TS docs include a playground example for more on that.

Structural typing is very convenient in many contexts, but in practice, it can be the reason you don’t get a nice error message when you are expecting one.

Our new EmptyObject type lives under the rules of TypeScript’s structural type system, too. However, because of the Symbol key inside of EmptyObject, TS can still help us sometimes:

Copy Code
// we want `b` to be empty. How can we break it?
let b: EmptyObject 

// 1) Direct assignment
b = {foo: 'bar'} // Error as desired, no surprise here.


// 2) Indirect assignment
const looseB = {foo: 'bar'}

// The error here is pleasantly surprising:
// Type '{ foo: string; }' has no properties in common with type 'EmptyObject'.
b = looseB


// 3) Using `{}` again
const evenLooserB: {} = {foo: 'bar'}
b = evenLooserB // No error 😢. The {} type strikes again!

b = looseB in example (2) above produces an error because b is supposed to be of shape EmptyObject, and looseB does not contain the key [emptySymbol]. In fact, it can’t, both because that key is of type ?: never and because we don’t export the unique Symbol emptySymbol!

Final thoughts

The next time you reach for the {} type to mean “only an empty object”, try out defining your own EmptyObject type instead!

And, if the idea of creating your own custom utility types like EmptyObject piqued your interest, you may enjoy our longer post on utility types in TypeScript.

Notes
Written by

Monroe Ekilah is a principal software engineer at Mercury.

Share
Copy Link
Share on Twitter
Share on LinkedIn
Share on Facebook