Draft

Type Assertions vs Default Values in Typescript + JSDoc

A practical example of using type assertions, despite their discouragement

Typically, usage of the as keyword in Typescript is frowned upon, for good reason. Type-assertions represent a place in code where you're saying that you know better then tsc. Sometimes you do, often you don't.

Others have written at length on this subject, so I don't want to enumerate the good/bad use-cases again here. Instead, I wanted to highlight a time when it's not only a good idea, but can actually help you design better programs.

Example: XState machine definition

In XState, context is a piece of internal state that you end up accessing from various places, and schema.events represents a union of possible events that this machine accepts.

Of all the things you might want to be 'strongly typed' in your application, internal data and incoming events should be right at the top of the list.

This is why you'll often see the following pattern in use:

const machine = createMachine({
  id: 'dialog',
  context: {} as { id: string; },
  schema: {
    events: {} as
      | { type: "OPEN" }
      | { type: "CLOSE" }
  },
  states: {
    "closed": { on: { "OPEN": "open" } },
    "open": { on: { "CLOSE": "closed" } },
  },
})
  • It's just a simple/demo machine definition, we're not focussing on any actual functionality here.
  • Note how both properties (the highlighted lines) lines are using {} (empty object) as the values, followed by as ..., that's the type assertion part. The value is just a plain old object, but we're lying to Typescript, and asking it to treat context as though it was { id: string } and schema.events as though it was a union of those two objects.
  • The interesting part here, is that we're doing this only to provide types, nothing else.
    • Consumers of this machine will be forced to provide id: string as a context property. ✅
    • Likewise with schema.events - this will ensure anyone sending messages to this machine can only send one of the messages we defined. ✅
  • So it's all about defining types, or contracts. We aim to make fault free programs and this kind of code-level documentation that gets enforced by Typescript can help us along that path.

Trying default values instead

Because type-assertions are sometimes frowned up, I wanted to take a moment to consider alternatives ways to handle the typing of that context.id property in the example above.

Hopefully you'll see, that used in the correct places, type-assertions can make a lot of sense. Why? Because we can use them as an alternative to a default value.

So taking an alternative path for illustrative purposes, we could model this problem by using string | null as the type of the id field instead: (undefined would work nicely too, but space.)

const machine = createMachine({
  id: 'dialog',
  context: { id: null } as { id: null | string },
  schema: {
    events: {} as
      | { type: "OPEN" }
      | { type: "CLOSE" }
  },
  states: {
    "closed": { on: { "OPEN": "open" } },
    "open": { on: { "CLOSE": "closed" } },
  },
})
  • This highlighted line shows how we can set an initial, or default value for id, using null to represent the absence of the actual string value.

  • The part that follows, as { id: null | string }, ensures that Typescript will allow us to assign a string value later

    • Without this, we'd be stuck with only null being an assignable type
  • This is quite a nice pattern if a default value makes sense to your particular problem.

    • For instance, perhaps the id is actually unknown until some asynchronous operation has completed. In that case, id can really be null or a string.
  • If you find yourself in this kind of situation, then the way we've set this id field up is really accurate. It's null now (the default), but can be also become a string later.

  • You'll get help from Typescript too. Whenever you try to use id as a string, you'll be reminded that it could be null, so you'll have to check. Being honest like this about the nullability of a value should prevent some of the most common Javascript bugs (accessing values that are absent). ✅

JSDoc

For completeness, in JSDoc we can achieve the same thing with the following:

createMachine({
  id: 'dialog',
  context: {
    /** @type {string | null} */
    id: null
  },
  schema: {
    events: /** @type {Events} */ ({})
  },
  states: {
    "closed": { on: { "OPEN": "open" } },
    "open": { on: { "CLOSE": "closed" } },
  },
})

When a default value makes no sense

The previous approach, making id have the type string | null, is fine if it's a true reflection of the way your program is structured.

The flip side though, is when you know that your component or state-machine could never have come into existence without an id. In that case making it nullable in the initial context definition is no longer accurate - it's just there to please Typescript.

I've seen this (and done it myself!) on many occasions - they are plenty places in Typescript codebases where a type is 'wider' than it needs to be - a simple ? on a property when it's not actually optional, or like in our example, providing a default for something that actually can never actually take that value.

This kind of problem creates a knock-on effect too. Using wider types than needed, like string | null when just string would've been enough, means that you'll have to keep narrowing it everytime you try to use it.

const [state, send] = MachineContext.useActor()
const id = state.context.id;
//                       ^^ null | string
  • Notice how the type from the initial machine definition comes through here - context.id cannot be used directly, even though we know that it can never actually be null due to how the rest of the program is structured.

So, when I am forced to deal with this, I prefer to halt the program, considering it a completely invalid state.

import invariant from "tiny-invariant";

// later
const [state, send] = MachineContext.useActor()
const id = state.context.id;
invariant(typeof id === string, "")

console.log(id)
//          ^^ string
  • this highlighted line checks the given condition for truthiness, and will throw an exception if it fails.
  • then, thanks to control-flow analysis, subsequent lines can now freely use id as a string, since Typescript knows that's the only type assignable to it.
    • Before this check, id had the type string | null, but the check typeof id === string effectively erases the possibility of it being null, leaving only string

If you are not using something like tiny-invariant, you can cause the same effect on control-flow analysis with a manual throw

-invariant(typeof id === string, "unreachable, id must be a string")
+if (typeof id !== string) throw new Error("unreachable, id must be a string")

Avoiding runtime validation when possible

These runtime checks that only exist to please Typescript are a bit of a necessary evil - they can be used for good, but we should try to recognise which of them are required, and which can be made redundant with a better design.

Side note: I'm not referring to the rise in popularity of tools that verify external data - you should be validating everything that comes into your application anyway, ideally using tools that have a nice integration with Typescript, like Arktype, Zod, Valibot, etc.

In relation to the examples given in this post, a 'better design' would be to prevent using a wider type than needed. In short, that means getting back to the example we opened with, where a blank {} is used, along with a type-assertion to force Typescript into believing our context has the type { id: string }.

-   context: { id: null } as { id: null | string },
+   context: {} as { id: string },
  • Because of the way XState works, users of this machine will need to provide a valid context and because we've now removed the null, consumers will understand that a valid id is part of the contract.

Now, the design of the types actually reflects the usage in code - if there's never a way that id can actually be null, then we should strive to avoid it and not resort to a default value of null or undefined.

Avoid colouring your code

The concept of 'colouring' has been spoken about before, often about languages that support async/await. The idea is that the moment you make a function async, you're now forcing the caller of your function to handle the fact that it won't yield a value immediately. They'll have to use APIs or language features to 'wait' for the value, or to 'unpack' it.

Once you alter your code to handle someone else's latency like this, you inevitably have to alter your API too so that your callers know they can no longer expect a result synchronously. It spreads outwards, on and on. Before you know it, functions that have no business being async or knowing anything about program latency can end up being 'coloured' in this way.

Whilst perhaps not to the same degree, I consider the 'width' of types, or the amount of types that are assignable to a value, to also be a form of colouring. If the value has a single assignable type, I'd consider it to be the same colour as a regular synchronous function. In both cases, consumers don't have to deal with the additional domain knowledge - they can just use the functionality as described on the box.

But, if you include too many types, like string | null when just string would have done, you're now forcing other places in the program to have to narrow the type before usage. It's a different colour now, and consumers will need to look up how to deal with your leaked implementation detail - worse, they may even need to change their colour to suit yours!

Summary

Of course, I'm just using this fictional id field and the type string | null as a way to represent the type widening/narrowing problem in a simple, concise way. But, it's the concept that's important - I'm sure you can expand this idea to cover more complicated types that you've worked with.

Is there somewhere you can remove an optional property? Is there a null or undefined value that can be removed altogether with a different program design?

The design of your types has a much larger impact that it first appears. If you're going to spread one colour over another, try to ensure it's deliberate & accurate. If it's just in the name of pleasing Typescript or getting something done in a pinch, then put a todo on it and stick it in the tech debt category 💪.


JSDoc Code Examples for Reference

Whilst the as keyword cannot be used in JSDoc (not valid Javascript), you can achieve the same thing.

// ts
context: {} as { id: string | null; }
// jsdoc
context: /** @type {{id: string | null}} */({})
  • note how in the JSDoc example there's an extra () around the {}, this is how you get @type to apply directly to the subsequent expression.

Just like in Typescript, if you want to move your types into separate definitions, you can either use @typedef in the same file

/**
 * @typedef {{type: "OPEN"} | {type: "CLOSE"}} Events
 * @typedef {{id: string}} Context
 */
createMachine({
  id: 'dialog',
  context: /** @type {Context} */({}),
  schema: {
    events: /** @type {Events} */ ({})
  },
  states: {
    "closed": { on: { "OPEN": "open" } },
    "open": { on: { "CLOSE": "closed" } },
  },
})

Or, you can use a .ts file, just ensure you only put types it in:

// types.ts
export type Events =
  | { type: "OPEN" }
  | { type: "CLOSE" }

export interface Context {
  id: string
}

With that types.ts file, you can then import types directly

createMachine({
  id: 'dialog',
-  context: /** @type {Context} */({}),
+  context: /** @type {import("./types.ts").Context} */({}),
  schema: {
-   events: /** @type {Events} */ ({})
+   events: /** @type {import("./types.ts").Events} */ ({})
  },
  states: {
    "closed": { on: { "OPEN": "open" } },
    "open": { on: { "CLOSE": "closed" } },
  },
})