Using TypeScript’s singleton types in practice

Tar Viturawong
14 min readSep 29, 2018

--

Some rocks are special. So are some primitive literals. Photo credit: Austin Neill on Unsplash

To many of us who delight in JavaScript’s flexibility, TypeScript may appear at first like “a step backwards” to some forced object-oriented patterns and maintaining a ganglion of type declarations. However, such perception is hardly an accurate depiction of TypeScript, whose developers have made appreciable effort to embrace the living idioms of JavaScript, while letting TypeScript do the hard work on its own to a great degree.

One such effort is portrayed in a TypeScript feature known as “singleton types”. I will first show we can use TypeScript’s singleton types to make JavaScript code safer and more traceable, from a theoretical perspective. In the second part, I will relate some of my practical experience in putting singleton types into good use in more complex situations.

Part one: The promises of singleton types

Singleton typing — also known as “literal typing” — is when TypeScript sees a specific primitive value as exactly that value, rather than a value of that primitive’s type. For instance, the value "Medium" can be seen as type string, but it can also be seen as type "Medium" i.e. exactly that string value, which is a subset of type string (along with "cat", "dog" , "broccoli" , and everything else)

When you declare numeric or string constants using literals, they will have singleton types.

const x = "cat";   // Type: "cat"
const y = 20; // Type: 20

Likewise, when you return a string or numeric literals, they too will have singleton types.

// Return Type: "ComeDancing"
function strictlyComeDancing() {
return "ComeDancing";
}

You can also define a singleton type explicitly

type ExactlyCat = "cat";
type ExactlyTwenty = 20;

You can also bundle a set of semantically related literal types in “enums”:

enum PetKind {
cat = "cat",
dog = "dog",
hedgehog = "hedgehog"
}
const myPet = PetKind.cat;
myPet === "cat"; // true
myPet === PetKind.cat; // true
myPet === "dog"; // Type Error

An extra niceness about enums is that they have 100% semantic distinction. Even if you have two enums with same values, they cannot be compared.

enum PetKind {
cat = "cat",
dog = "dog",
hedgehog = "hedgehog"
}
enum ConservationAnimalKind {
hedgehog = "hedgehog",
elephant = "elephant"
}
PetKind.hedgehog === ConservationAnimalKind.hedgehog // ERROR

“OK, nice. But how is this useful?”

Singleton types don’t do much on their own, but they play an important part in some quite powerful typing techniques. We’ll explore them now.

Maintaining conditional expression integrity

Suppose we have a function that validates — superficially for the sake of illustration — an input field that should be a valid email address, and we want more than information that whether the email is valid or not (i.e. why it might be invalid):

function validateRequiredEmail(value: string)
{
if ( !value ) return "empty";
if ( value.indexOf('@') < 0 ) return "noAtSign";
return null;
}

The return type of validateRequiredEmail is a union of null (for no error) and two singleton types indicating error, namely: "empty" and "noAtSign". TypeScript infers this from the function body, so we don’t have to annotate the return type.

When you actually call this function, TypeScript will make sure you are making only sensible assertions over the return result.

const validationResult = validateRequiredEmail("not an email");
if ( validationResult === "empty" ) doThis();
else if ( validationResult === "noAtSign" ) doThat();
else if ( validationResult === "broccoli" ) doSomethingElse();

The last else if statement will fail because validationResult , although it can be a string, can never return "broccoli". In reality, if you assert against "broccoli" , you probably expect it to be one of the outcomes, and here it isn’t, so something is probably wrong. TypeScript is flagging code integrity issues to you. You would not get this shout-out if validateRequiredEmail returned just a string type.

Discriminated unions

We have seen how a union of singleton types help writing safer conditional statements. The idea above can then be extended in pattern called discriminated union.

If each type making up a union shares a common singleton-typed property, we can “discriminate the union” — choose to narrow on a specific one — by disambiguating this singleton-typed property.

To demonstrate this, let’s look at the code below:

interface Cat
{
kind: "cat";
name: string;
isRescued: boolean;
likesFurball: boolean;
isAfraidOfDogs: boolean;
chasesBirds: boolean;
}
interface Dog
{
kind: "dog";
name: string;
likesMilkbones: boolean;
chasesCats: boolean;
}
interface Hedgehog
{
kind: "hedgehog";
name: string;
variant: "african"|"european";
}
type Pet = Cat|Dog|Hedgehog;

Disclaimer: hedgehogs are only legal as pets in some areas of the world!

Now suppose we have a variable pet of type Pet. Let’s try to do a few things with it:

console.log(pet.name);    // OK
console.log(pet.variant); // ERROR

Without any if condition, it’s not possible to access pet.variant , which is only specific to Hedgehog. To access the variant, you need to assert that your pet is a hedgehog before:

if (pet.kind === "hedgehog") {
console.log(pet.variant); // OK
console.log(pet.chasesBirds); // ERROR
}

The if block acts as a type guard for Hedgehog by asserting the discriminant property of the discriminated union, namely the kind property. Inside the if block, pet is now a Hedgehog and only a Hedgehog : TypeScript will not let us ask if our hedgehogs can chase birds.

Discriminating overloads

Another TypeScript feature benefiting from singleton types is the ability to annotate a function’s multiple overloads, so that it can be called with different combination of arguments. Perhaps the most well known example is the addEventListener method, which accepts a different kind of event handler depending on which event you’re attaching.

myElement.addEventListener('keypress', ev => { /* ... */ } );
myElement.addEventListener('mousemove', ev => { /* ... */ } );

When you call addEventListener with "keypress", the ev object will have the key property that gives you the key code of the key that was pressed. This property will not be available in ev of the callback for"mousemove", which would instead have mouse coordinate properties like clientX.

This kind of overload is pretty idiomatic of living JavaScript, and typing such overloads exactly would not be possible without singleton types.

Exact indexing

Take the example from our validateRequiredEmail function’s return type again. Suppose that we want to then map our error codes to some user-facing text, we could use a series of if / else clauses as we have shown above.

However, this is not very re-usable as a pattern, and does not scale very nicely if there are more possible outcomes. What we could do instead is just use our return value as an index to an object that provides those keys.

const errorMessages = {
empty: "This field is required.",
noAtSign: "This does not look like a valid email."
};
const validationResult = validateRequiredEmail("not an email");
if (validationResult) {
console.log(errorMessages[validationResult]);
}

Since we guard validationResult for truthiness, the null value falls away, leaving only "empty"|"noAtSign" as its type. This type is perfectly good to index the object errorMessages, whose properties are also exactly empty and noAtSign.

At this point, if you delete a property from errorMessages, TypeScript will complain that validationResult can no longer be used to index the object. If you let validationResult return another value (like "broccoli"), TypeScript will complain in the same way… until you add broccoli as a property to errorMessages too. Here also, the singleton types have better safety and traceability than string types.

Part two: The practical gotchas

“To know a thing well, know its limits; Only when pushed beyond its tolerance will its true nature be seen.”

― Frank Herbert, Children of Dune.

Hopefully I have made a convincing pitch for why singleton types are awesome in principle. Now I will switch role to play the sharks’ advocate, and show you some obstacles you might face.

One of the reasons why I love TypeScript is its intention to give you a lot of code integrity check with minimal efforts. I’m talking about the fact that one should be able to minimally add types where needed, and TypeScript will infer the rest. We have seen a couple of examples of this in the first part.

To do this, TypeScript needs to make some assumptions on our behalf. Unfortunately, this is also where leaving things fully to TypeScript can make things break, sometimes for seemingly incomprehensible reasons. There are a couple of subtle things that I want to share.

When TypeScript sees a singleton type

Having marveled at how discriminated unions can help giving sense to my data structures, I wanted to create a function that wraps Axios in making a specific HTTP request, and returns a union of outcomes that are relevant to my application’s semantics. I innocently charged ahead and created this function below.

import { default as axios, AxiosError } from "axios";
async function login(username: string, password: string) {
try {
const response = await axios.post("/api/login", {
username,
password
});
return { isSuccess: true, sessionID: response.data as string };
} catch (ex) {
const error: AxiosError = ex;
const { response } = error;
if (!response) {
return {
isSuccess: false,
errorType: "network"
};
}
if (response.status === 401 || response.status === 403) {
return {
isSuccess: false,
errorType: "unauthorized",
isBlocked: response.status === 403
};
}
return {
isSuccess: false,
errorType: "unexpected",
serverMessage: response.data as string
};
}
}

Basically, I wanted there two be two levels of discriminated union: first, if and only ifisSuccess is exactlyfalse, then I will have access to errorType (compare my Hedgehog's variant from Part One). This in turn is another singleton type that can let me distinguish between network error, authorization error, or unexpected error, each of which has its own data type.

It turned out nothing worked as I expected.await -ing the result of this function gives me a structure that does not change under any kind of guard. What’s more, errorType is not a singleton type of any kind but just plain string. So much for my grand plan!

But we did see that validateRequiredEmail function that was correctly returning singleton types and this login function. What what went wrong? After a few rounds of elimination, it turned out that:

TypeScript only sees singleton types in primitive literals that are directly returned, are directly passed as function arguments, or are directly assigned to constants, but not those nested in objects or arrays.

To illustrate what I mean, here are a few expressions:

const literal = "hedgehog";             // Type: "hedgehog"
const object = { kind: "hedgehog" };
const objectProperty = object.kind; // Type: string
const array = ["hedgehog"];
const arrayItem = array[0]; // Type: string
const returnHedgehog = () => "hedgehog";
const returnValue = returnHedgehog(); // Type: "hedgehog"
const returnObject = () => ({ kind : "hedgehog" });
const returnObjectProperty = returnObject().kind; // Type: string
const returnArray = () => ["hedgehog"];
const returnArrayItem = returnArray().kind; // Type: string

I was a little disappointed at this, because I was hoping to get away with only the type annotation I absolutely needed. But with a bit of pedantry, I could tell TypeScript to treat object literal properties as singleton types.

import { default as axios, AxiosError } from "axios";
async function login(username: string, password: string) {
try {
/* ... */
return {
isSuccess: true as true,
sessionID: response.data as string
};
} catch (ex) {
/* ... */
if (!response) {
return {
isSuccess: false as false,
errorType: "network" as "network"
};
}
if (response.status === 401 || response.status === 403) {
return {
isSuccess: false as false,
errorType: "unauthorized" as "unauthorized",
isBlocked: response.status === 403
};
}
return {
isSuccess: false as false,
errorType: "unexpected" as "unexpected",
serverMessage: response.data as string
};
}
}

(Make sure strictNullChecks is configured to true in your tsconfig.json , otherwise you may see different effects in what I’m demonstrating.)

Now I have my doubly-discriminated union! [Edit: note in the block below, wherever we discriminate using &&, each comment gives the type of the expression to the right of &&, not the type of the entire expression. Thank you to Vadim Alferev for pointing that out!]

const result = await login('blah', 'blah');
result.isSuccess // Type: boolean
result.sessionID // Type: string|undefined
result.isSuccess && result.sessionID // Type: string
!result.isSuccess && result.sessionID // Type: undefined
// Type: "network"|"unauthorized"|"unexpected"|undefined
result.errorType
result.isSuccess && result.errorType // Type: undefined
// Type: "network"|"unauthorized"|"unexpected"
!result.isSuccess && result.errorType
result.errorType === "unexpected" && result.isSuccess // Type: false
result.errorType === void 0 && result.isSuccess // Type: true
result.errorType === "unexpected" && result.isBlocked // undefined
result.errorType === "unauthorized" && return.isBlocked // boolean

Implicit vs explicit discriminated unions

In the above example, I added a minimal type enforcement to get the discriminated union to work. But there was still something strange going on…

Earlier, with the Pet discriminated union, I wasn’t able to access a property that only existed on a type not matched by my type guard without a typing error (my Hedgehog couldn’t chase birds). But just now, note that I was able to get errorType directly without discriminating isSuccess first.

It turned out that the difference is because when TypeScript infers a discriminated union, it tries to “fill in the hole” such that the return type always has the same properties. Wherever the property isn’t defined in my object literal, TypeScript fills it with type undefined .

If, instead, I define the types of each outcome and then explicitly use it as the return type, I now have the same behavior as my Pet discriminated union. Here is how to do it:

type SuccessfulLogin = {
isSuccess: true;
sessionID: string;
};
type UnauthorizedError = {
isSuccess: false;
errorType: "unauthorized";
isBlocked: boolean;
};
type NetworkError = {
isSuccess: false;
errorType: "network";
};
type UnexpectedError = {
isSuccess: false;
errorType: "unexpected";
serverMessage: string;
};
async function login(
username: string,
password: string
): Promise<
SuccessfulLogin | UnauthorizedError | NetworkError | UnexpectedError
> {
try {
/* ... */
return { isSuccess: true, sessionID: response.data as string };
} catch (ex) {
/* ... */
if (!response) {
return {
isSuccess: false,
errorType: "network"
};
}
if (response.status === 401 || response.status === 403) {
return {
isSuccess: false,
errorType: "unauthorized",
isBlocked: response.status === 403
};
}
return {
isSuccess: false,
errorType: "unexpected",
serverMessage: response.data as string
};
}
}

Note also that with this pre-declaration, I no longer need to force my object properties into singleton types (no more of this true as true business!).

Now let’s look at some type testing. I am now unable to access type-specific properties without discriminating that type from the union first.

(async () => {
const result = await login("blah", "blah");
result.sessionID; // ERROR
!result.isSuccess && result.sessionID; // ERROR
result.errorType; // ERROR
!result.isSuccess && result.errorType; // ERROR

if (!result.isSuccess) {
result.errorType // OK
result.errorType === "unexpected" && result.isBlocked; // ERROR
result.errorType === "unauthorized" && result.isBlocked; // OK
}
})();

Again, this subtle difference in the behavior is a consequence of TypeScript’s inferred type not being quite as exact as our intended type. But we can see they they are both equal in safety: either a value is always undefined so you can’t really use it anyway, or the value cannot be accessed to begin with.

So here are two versions that represent a trade-off between brevity and strictness. I personally don’t see the implicit version as being problematic, and unless I feel that the outcomes are so complex that there needs to be explicit type documentation, I prefer the path of minimal type footprint.

Unintended type narrowing

TypeScript’s well-intended automatic type inference can get in your way of singleton types in other subtle ways. Here’s another example that I came across.

Suppose that you have a class that represents an input value and its computed validity. The input can be any string value, and you can pass any arbitrary validation function. To illustrate the problem related to typing, we don’t need to bother implementing the actual functionality.

class Input {
constructor(
readonly defaultValue: string,
readonly isValid?: (value: string) => boolean) {
}
}

Now suppose that I want to use singleton types for the input value (for instance to represent exact choices), I can make this class generic:

class Input<T extends string> {
constructor(
readonly defaultValue: T,
readonly isValid?: (value: T) => boolean) {
}
}

This works well when you pass an explicit union of singleton types. I have an input whose values are exact choices.

const department = new Input("" as ""|"development"|"research");
department.defaultValue; // Type: ""|"development"|"research";

But now TypeScript’s rule that literals are treated as literal types will backfire on us. Let’s look at this one:

const myInput = new Input("");
myInput.value // Type: ""

Uh oh, that’s not what we intended. There’s no reason why we would have an input with just "" as the only possible value! Instead, this is what we want:

const myInput = new Input("" as string) // I want any string!
myInput.value // Type: string

Unintended type widening

Brace yourselves, things are about to get a little crazy.

We will now start adding that second argument to our constructor: the validation predicate. Let’s suppose we add a simple “required” rule:

const department = new Input(
"" as ""|"research"|"development",
value => value.length > 0 // required
);
department.defaultValue // Type: ""|"research"|"development";

The type of the input value is still correctly a union of singletons. Now let’s say I want to make the required rule reusable. Since the other uses might not be expecting exact choices, I make the parameter of requiredRule a string. So:

const requiredRule = (value: string) => value.length > 0;
const department = new Input(
"" as ""|"research"|"development",
requiredRule
);
department.defaultValue // Type: string

We just lost our singleton union!

I also want to add that, at this point, if this input value is being used to index an object with exact keys, you will get this totally cryptic error where the indexing takes place:

const copyText = 
{
"": "Choose",
"research": "Research",
"development": "Development"
};
copyText[department.defaultValue] // EXPLODES

Element implicitly has an ‘any’ type because type ‘(the type of your structure)’ has no index signature.

What that message means is that TypeScript cannot index copyText — which expects one of the three singleton types as the index — with just any string value. The phrase “has no index signature” means just that: “you can’t just index this with any old thing, please give me just the exact keys that I accept”.

To understand what’s going on here, we need to re-visit the declaration of Input :

class Input<T extends string> {
constructor(
readonly defaultValue: T,
readonly isValid?: (value: T) => boolean) {
}
}

What is happening is that TypeScript is inferring the type parameter T of input from both the default value and the validation function. Since requiredRule explicitly accepts string, TypeScript infers T to be the broadest type that bothstring and the singleton string types satisfy, and that type is string .

Now let’s try this one:

const requiredRule = (value: string) => value.length > 0;
const department = new Input(
"" as ""|"research"|"development",
value => requiredRule(value)
);
department.defaultValue // Type: ""|"research"|"development"

We got the singleton union back! But why?

What’s now happening is that the validation predicate does not enforce a type: value => requiredRule( value ) does not annotate the type of the input, so TypeScript can’t use it to infer T. This makes the default value the sole determinant for T , and it is the singleton union.

While it is a beautiful idea that TypeScript can infer everything for us, in reality it doesn’t always work the way we want. Nevertheless, if we understand TypeScript’s tendencies in type inference, we can get the full intended power of the pattern if we give TypeScript a little nudge here and there.

Conclusion

I have shown how singleton types and discriminated unions can bring extra safety and traceability to JavaScript projects, while still embracing important JavaScript idioms. This is at least in principle. In practice, there are a few subtleties that are consequence of the opinions TypeScript forms when automatically inferring types in more complex scenarios. I have shown a couple of gotchas which others hopefully find helpful in troubleshooting similar obstacles.

I personally am a firm believer in the TypeScript project. Although I have demonstrated plenty of unintuitive artefacts, I still believe in the value that it promptly delivers when it gets all the information it needs. I am planning to write similar articles about other TypeScript patterns in practice, with the aim of realizing the tool’s full potential. Stay tuned!

--

--

Tar Viturawong

I write dev articles. I code, love, laugh, cry, eat, goof, screw up, celebrate, and wonder.