• Home
  • Blog
  • How to Define String and Literal Types Without Losing IntelliSense in TypeScript

How to Define String and Literal Types Without Losing IntelliSense in TypeScript

This article was published on May 11, 2024, and takes approximately 3 minutes to read.

While writing Typescript code, there's always a line between making the type correct and the experience we have consuming the code.

Let's see one example:

interface User {
  role: string
}

In this example, the user role is any string, which is fine.

When creating a user, we can pass any string:

const userA: User = {
  role: "admin"
}

const userB: User = {
  role: "writer"
}

const userC: User = {
  role: "" // also valid
}

On the other hand, we could use string literals and define beforehand the roles our users could possibly have:

interface User {
  role: 'admin' | 'writer'
}

Now, we only can pass one of the two values. Any other string will be accepted.

The good thing about the literal string is that when you're in a code editor that has good integration with TypeScript, you have this kind of IntelliSense:

Editor hinting the possible values
Editor hinting the possible values

That's cool because it helps with typos, especially for strings. We know the value.

But what if we need something in between? What if we know a few values but need to let it open to any string?

You could say: That's easy. We define the string literal OR string:

interface User {
  role: 'admin' | 'writer' | string
}

And this defeats the IntelliSense we had before. Now, if we inspect, role becomes a string again:

(property) User.role: string
role type string
role type string

Union Types and Supertypes

In TypeScript, the union type A | B means a value that can be either of type A or type B. However, when you have a union where one of the types is a supertype of the other, the union is simplified to just the supertype.

  • Literal Type: admin is a literal type, which is a subtype of string.
  • String Type: The string type is the supertype of all string literal types (like 'admin', 'user', etc.).

Since string is a supertype that encompasses all possible string literal types, including 'admin', the union 'admin' | 'writer' | string is simplified by TypeScript's type system to just string.

In other words, the type is correct, and TypeScript is doing what it's supposed to, but we lose what I like the most while writing TypeScript, IntelliSense, and Developer Experience (DX).

The workaround

I found it really hard to find the right searchable terms to encounter a solution for this (that's why I'm writing this), but I did.

Of course, I'm not the first person to have this issue, and I'm wondering how to work around it, and finally I found a "hack" using intersection.

The tricky here is the following:

interface User {
  role: 'admin' | 'writer' | (string & {})
}

Now, while writing a user, the IDE will suggest the literals we expect and allow us to pass any string we might want:

Back with good InteliSense
Back with good InteliSense

Also, the type inference will no longer fall to a supertype `string`, but all those types altogether:

(property) User.role: "admin" | "writer" | (string & {})

But why the heck does this work?

Intersection

In simple terms, the intersection is when we have two types simultaneously (A & B).

In our case, we're basically fooling TypeScript. We're telling TS that the last type ((string & {})) is a string AND an empty object simultaneously, not "a string".

For practical effects, since we're unioning string with an empty object (a neutral type), TS will understand it as a string but not the supertype.

Maybe we can think of it as a "string-ish" value—something that looks, behaves, and should be treated as a string but isn't a string itself.

And that's why the type inference looks like this:

(property) User.role: "admin" | "writer" | (string & {})

and not like this:

(property) User.role: string

Bonus tip: LiteralUnion type

Writing | (string & {}) can become tedious and unclear for others.

If you want to have a better declaration, you could create a generic type that does this magic trick for you:

type LiteralUnion<T extends string> = T | (string & {})

Then, use it in a more declarative way:

interface User {
  role: LiteralUnion<'admin' | 'writer'>
}

You can add it globally to your app via a globals.d.ts file or declare it a type utility file you must import.

Conclusion

TypeScript isn't hard, but it's full of tricks.

Use this trick to help your future self while consuming functions. You know the string values we expect, especially if you're in the author library. There's nothing more frustrating than having to pass strings, and the known value is declared in some website documentation.

References