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.
In the above example, myWorldName is inferred by TypeScript to be of type string
. Note the subtle difference in the following code.
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.
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.
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.
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.
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:
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.