The power of const assertions

tkdodo

Dominik D

Posted on January 4, 2021

The power of const assertions

const assertions were introduced in TypeScript 3.4 (March 2019), so they are not exactly new, yet I have seen that many developers are still unaware of that feature.

Maybe it's the syntax that makes it a bit weird sometimes (writing const something = ... as const). It might also be it's the resemblance to type casts that make people afraid of using them. Or possibly, you just got some weird errors regarding readonly, so you decided to not dig deeper.

In this article, I'd like to clear up the confusion and crush all doubts about const assertions.

const assertions are not type casts

Type casts are, simply put, evil. They are meant to tell the compiler: "I know what I am doing, and I know it better than you".

Frankly, most of the time, developers do not know better than the compiler. So unless there is a really good reason, do not use type casts.

Here are some examples of what type casts allow you to do:

type Foo = 'foo'
const foo = 'bar' as Foo

type Obj = { foo: Foo }
const obj = {} as Obj
Enter fullscreen mode Exit fullscreen mode

TypeScript playground

TypeScript is fine with that, because the types sufficiently overlap (string with 'foo' and object with Obj). Of course, that is just false, but by using type casts, the compiler will yield to you.

This can be troublesome, even in cases where you think you are right. Consider the following example:

type Variant = 'primary' | 'secondary'

type Props = {
    variant: Variant
}

const Component = (props: Props) => null

const props = { variant: 'primary' }

Component(props)
Enter fullscreen mode Exit fullscreen mode

TypeScript playground

Here, the compiler will complain with:

Argument of type '{ variant: string; }' is not assignable to parameter of type 'Props'.
  Types of property 'variant' are incompatible.
    Type 'string' is not assignable to type 'Variant'.(2345)
Enter fullscreen mode Exit fullscreen mode

because variant will be inferred to string. TypeScript is doing this because nothing stops you from re-assigning another string to variant:

type Variant = 'primary' | 'secondary'

type Props = {
    variant: Variant
}

const Component = (props: Props) => null

const props = { variant: 'primary' }
props.variant = 'somethingElse'

Component(props)
Enter fullscreen mode Exit fullscreen mode

TypeScript playground

Even though we define a const, objects in JavaScript are still mutable, so inferring the string literal 'primary' would be wrong. A type cast would solve this:

type Variant = 'primary' | 'secondary'

type Props = {
    variant: Variant
}

const Component = (props: Props) => null

const props = { variant: 'primary' } as Props

Component(props)
Enter fullscreen mode Exit fullscreen mode

TypeScript playground

All good - except, it isn't. For the same reasons I mentioned earlier, if we remove primary from our Variant type, we will not get a type error here. This means that, like many solutions, this is something that works now, but is not very future proof.

Making your software resilient to change is, in my opinion, one of the true benefits of using TypeScript. Achieving resilience requires the right mindset, which includes abandoning type casts.

For this scenario, the easiest solution (assuming that inlining the object is not an option) would be to use an explicit type annotation rather than a type cast:

type Variant = 'primary' | 'secondary'

type Props = {
    variant: Variant
}

const Component = (props: Props) => null

const props: Props = { variant: 'primary' }

Component(props)
Enter fullscreen mode Exit fullscreen mode

TypeScript playground

This is likely what most of you are doing right now, and it is perfectly fine regarding type-safety.

Using const assertions

I still think that fixing the issue with const assertions is the preferred way of doing it:

type Variant = 'primary' | 'secondary'

type Props = {
    variant: Variant
}

const Component = (props: Props) => null

const props = { variant: 'primary' } as const

Component(props)
Enter fullscreen mode Exit fullscreen mode

TypeScript playground

This comes in handy if you don't have the type available for annotation, for example, because it has not been exported from a library you are using. The syntax is also more terse, and using const assertions has other benefits as well. Because you are signalling TypeScript that your object is really constant, the compiler can make better assumptions about your intentions:

  • strings and numbers can be inferred as their literal counterparts
  • arrays become tuples with a fixed length
  • everything is readonly, so you cannot accidentally mutate it afterwards (looking at you, Array.sort)

This will give you a ton of flexibility when working with that constant on type level.

Extracting Types from Objects or Arrays

Consider the following example:

type Variant = 'primary' | 'secondary'
type Option = { id: Variant; label: string }

const options: Array<Option> = [
    {
        id: 'primary',
        label: 'The primary option',
    },
    {
        id: 'secondary',
        label: 'The secondary option',
    },
]
Enter fullscreen mode Exit fullscreen mode

TypeScript playground

So far, so easy. If we want to add another variant, we just have to add it to the Variant type and to the options Array. This is fine as long as the code is co-located, and because we have explicitly annotated the options Array, but it can also become quite boilerplate-y pretty fast. With const assertions, you can just grab the type from the options Array:

const options = [
    {
        id: 'primary',
        label: 'The primary option',
    },
    {
        id: 'secondary',
        label: 'The secondary option',
    },
] as const

type Variant = typeof options[number]['id']
Enter fullscreen mode Exit fullscreen mode

TypeScript playground

We are basically telling the compiler: Walk through every item of options and give me the type of the id. Much terser syntax, the type will still be correctly inferred to primary | secondary, and we now have one single source of truth.

Of course, this only works because of the const assertion, and if you remove it or forget it, Variant will just be of type number. This is a problem because it relies on developers not making mistakes, and if we wanted to rely on that, we could also just write JavaScript.

Luckily, we can also tell the compiler to only make this work with readonly Arrays:

const options = [
    {
        id: 'primary',
        label: 'The primary option',
    },
    {
        id: 'secondary',
        label: 'The secondary option',
    },
] as const

type EnsureReadonlyArray<T> = T extends Array<any>
    ? never
    : T extends ReadonlyArray<any>
    ? T
    : never
export type Extract<
    T extends ReadonlyArray<any>,
    K extends keyof T[number]
> = EnsureReadonlyArray<T>[number][K]

type Variant = Extract<typeof options, 'id'>
Enter fullscreen mode Exit fullscreen mode

TypeScript playground

This is admittedly a bit hackish, as it relies on the fact that const assertions make everything readonly. But it will make sure that Variant is inferred to never if you forget the const assertion, and that means you won't be able to use it anywhere. I'll take that safety any day (and tuck Extract away in a util).

Use readonly everywhere

Finally, I'd like to point out something that became more apparent to me since I am constantly using const assertions:

Make everything readonly per default

— TkDodo

The thing is: You can pass a mutable Array or Object into a method that takes a readonly Array or Object, but not the other way around. The reason is quite simple: if my function accepts an Array, the function might mutate it, so you cannot pass a ReadonlyArray to it.

const getFirst = (param: Array<string>): string => param.sort()[0]

const strings = ['foo', 'bar', 'baz'] as const

getFirst(strings)
Enter fullscreen mode Exit fullscreen mode

TypeScript playground

Even if you wouldn't sort the Array, it would still error with:

Argument of type 'readonly ["foo", "bar", "baz"]' is not assignable to parameter of type 'string[]'.
  The type 'readonly ["foo", "bar", "baz"]' is 'readonly' and cannot be assigned to the mutable type 'string[]'.(2345)
Enter fullscreen mode Exit fullscreen mode

By making the parameter readonly, we just guarantee that we are not mutating it (which is always good - don't mess with function parameters) and we make people's lives easier if they are using const assertions in the process.

If you are a library author, I strongly recommend making all inputs to your functions readonly.


Do you also prefer const assertions? Leave a comment below ⬇️

💖 💪 🙅 🚩
tkdodo
Dominik D

Posted on January 4, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related