String types in TypeScript: Fundamentals

If you type a variable with as string, you can assign any other string to that variable without getting a compiler error.

A slightly different scenario is when defining a constant whose value is a string. In that case, there is no point to type the constant; rather we can rely on type inference as follows.

const myWorldName = 'Crypton';

type WorldName = typeof myWorldName; // string
Example 2: loose inference

In the above example, myWorldName is inferred by TypeScript to be of type string. Note the subtle difference in the following code.

const myWorldName = 'Crypton' as const;

type WorldName = typeof myWorldName; // 'Crypton'
Example 3: tightening up inference using as const

The as const modifier affects the type inference, so that myWorldName is inferred to be of the type 'Crypton'. Note that there is the literal value 'Crypton' and the type 'Crypton', i.e. the type of the value 'Crypton' is the type 'Crypton'.

While this may seem a bit silly, here's a more complex example.

const allWorldsLoose = ['Earth','Mars','Saturn'];

type AllWorldsLoose = typeof allWorlds; // string[]

const allWorldsTight = ['Earth','Mars','Saturn'] as const; // ('Earth' | 'Mars' | 'Saturn')[]

type World = (typeof allWorldsTight)[number] // 'Earth' | 'Mars' | 'Saturn'
Example 4: Using a mapped type to derive a string union type

In the above snippet, the type of allWordsLoose is string[] , but the as const modifier causes the type of allWorldsTight to be inferred as an array of values taken from the string union 'Earth' | 'Mars' | 'Saturn'. We can derive this string union type (calledWorld in the example) as a mapped type.

Defining a type guard for a string union

The type World in Example 4 is a union type. It is a union of the types (not to be confused with the corresponding values) 'Earth', 'Mars', and 'Saturn'. We can define a type guard that asserts that an input is of one of these three types as follows.

const allWorlds = ['Earth','Mars','Saturn'] as const;

type World = (typeof allWorlds)[number] 

const isWorld = (input: unknown): input is World => allWorlds.includes(input);
Example 5: Defining a type guard for our union type

The function isWorld is a type guard. This type guard takes in an unknown type. All typeguards have a return type that uses the is keyword to make a type assertion, input is World in this case. Functions with such a return type should return a boolean value. In any branch where the function returns true, the compiler will infer the value of the type guard's input to be World. Conversely, in branches where the type guard returns false, the compiler will infer that the value of input is not World.

The typeguard links the static and dynamic worlds. If you use a typeguard in branching logic, TypeScript will statically be able to narrow types for you. On the other hand, there are no types at run time in the JavaScript world. However, the logic you include allWorlds.includes(input) in this case, will be executed at runtime. This also means that you should take care to get your typeguard logic right!

Type Narrowing

Let's put our new type guard to work!

First note that as types go,

'Earth' < 'Earth' | 'Mars' | 'Saturn' < string

where I am using < to denote 'is a sub-type of'.

We can use type narrowing with string types. Consider this (arrow) function.

const getGreetingForWorld = (world: string): string =>{

	if(!isWorld(world)) return 'Greeting from unknown world';
	// typeof world is now World = 'Earth' | 'Mars' | 'Saturn'

	if(world === 'Earth') return 'Hello earthlings';
    // typeof world is now 'Mars' | 'Saturn'
    
    	if(world === 'Mars') return 'Hello Martians';
        // typeof world is now 'Saturn'
    
    	if(world === 'Saturn') return 'Go back to Mars';
        // typeof world is now never
}
Example 6: type narrowing

In the above example, getGreetingForWorld takes in a string. As we progress through the function, the type of the input world is narrowed from string-> World -> 'Mars' | 'Saturn' -> 'Saturn' -> never . If you hover over world on each line, you will see this type narrowing in action. Note this only happens because we are returning early after each if. If we do not return, we will not get narrowing.

const logWorldMessage = (world: string) =>{
	if(!allWorlds.includes(world)) console.log('Greeting from unknown world');
    // typeof world is still inferred as string here!
    
    if(world === 'Earth') console.log('Hello earthlings')
    ...
}
Example 7: No type narrowing without an early return

Exhaustive Check

In Example 6, it is clear that the intention is to create a unique message for each member of World. However, as it stands, we have no guarantee that we haven't forgotten one, and no safe guard to keep us from forgetting to add a new greeting when we add another member to World. Ask yourself what would happen if we were to update our list of worlds as follows.

const allWorlds = ['Earth','Mars','Saturn','Jupyter'] as const;

type World = (typeof allWorlds)[number] 

const isWorld = (input: unknown): input is World => allWorlds.includes(input);

Reread Example 6 with this change in mind and imagine that the function has been called with world= 'Jupyter'. Let's go through it line by line.

3: The type guard isWorld('Jupyter') returns true and so (!isWorld) returns false. We keep going (bypassing comments, of course).

6:  The world === 'Earth' check returns false. Keep going.

9: The world === 'Mars' check returns false. Keep going.

12: The world === 'Saturn' check returns false. Keep going.

14: We have hit the end of the function without encountering a return in the code path. What will happen here? In JavaScript a function with return statement will return undefined (note that I am not including an arrow function with an implicit return such as const getFive = x => 5, which clearly returns a number.)

So there we have it. Wherever we call getGreetingForWorld, we are probably expecting to receive a string! This could possibly cause things to explode, or at best, lead to some really weird output to the user!

One could argue that you should avoid switch statements (which is essentially what my sequential if statements are doing, even though I chose not to use the switch syntax). How to do so is beyond the scope of this discussion. However, one of the arguments for avoiding such code is that it's hard to track down all the places where you need to support an additional type when you add a member to a union type. Solving this problem fits really nicely within our current exposition.

The key is to realize that if you've done things right, the compiler should be able to infer that the type of world is never at the bottom of getGreetingForWorld. So you can assign a new variable, called exhaustiveCheck by convention, the value of world while typing it to never, as follows:

const getGreetingForWorld = (world: string): string =>{

	if(!isWorld(world)) return 'Greeting from unknown world';
	// typeof world is now World = 'Earth' | 'Mars' | 'Saturn'

	if(world === 'Earth') return 'Hello earthlings';
    // typeof world is now 'Mars' | 'Saturn'
    
    	if(world === 'Mars') return 'Hello Martians';
        // typeof world is now 'Saturn'
    
    	if(world === 'Saturn') return 'Go back to Mars';
        // typeof world is now never
        
        const exhaustiveCheck: never = world; // once you add `Jupyter` to `allWorlds`, you'll get a type error here
        
        throw new Error(`Failed to build greeting for unsupported world: ${world`);
}
Example 9: Adding an exhaustive check

Depending on your project settings, you may notice that after defining exhaustiveCheck you get an 'unused variable' error or warning. At any rate, it's bad practice to define a variable which you do not use. A natural way to resolve this is to actually use exhaustiveCheck in building an error message. In the example, we throw, in order to 'fail fast'. When you add 'Jupyter' to allWorlds, you can now hit the error statically when your build fails with compiler errors, or dynamically, presumably when one of your automated tests hits the throw.

Summary

We have covered a lot of distance in a short time. In the next section we will look at template literal types, a special kind of string types that was recently introduced into TypeScript.