- Published on
- -5 min read
Intermediate Typescript: Literals and Unions
Table of Contents
At my job we have spent a lot of time converting a node backend and angular frontend to Typescript. Before Typescript when working in our codebase I found myself having to read a lot of code, API schemas, and tests just to see what fields actually existed. So during the transition I tried my hardest to make the types I made as descriptive as they could be. Converting to Typescript and making big interfaces/types with many optional fields does not buy you much other than typo prevention and basic autocomplete.
This post assumes you have a basic understanding of Javascript/Typescript.
Literal types
You are most likely familiar with the basic types like
let let num: number
num: number = 1; // can be any number
let let str: string
str: string = 'hi'; // can be any string
let let bool: boolean
bool: boolean = true; // can be true or false
let let arr: number[]
arr: number[] = [10]; // can be an array of any length with numbers
let let obj: {
key: string;
}
obj: { key: string
key: string } = { key: string
key: 'value' }; // the key field can be any string
These types are fine for many cases and I still default most types to be these until I understand the code more.
Literal Types on the other hand are a much stronger restriction on what the allowed values are
const const numLiteral: 1
numLiteral = 1 as type const = 1
const; // this can only be the number 1, no other number
const const strLiteral: "literal"
strLiteral = 'literal' as type const = "literal"
const; // can only be the string 'literal'
const const boolLiteral: true
boolLiteral = true as type const = true
const; // can only be true
const const arrLiteral: readonly [10]
arrLiteral = [10] as type const = readonly [10]
const; // can only be an array with a single element of 10
const const objLiteral: {
readonly key: "value";
}
objLiteral = { key: "value"
key: 'value' } as type const = {
readonly key: "value";
}
const; // can only be this specific object mapping
These types on their own are not that useful but when combined with unions and conditional types they can make your types very powerful.
Unions
Union Types allow you to say a type is either foo
or bar
or number
or string
...
function function printId(id: number | string): void
printId(id: string | number
id: number | string) {
var console: Console
console.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)log('Your ID is: ' + id: string | number
id);
}
This function will allow you to pass in a string or number, this is fine since both can be added to a string for display.
When combined with literals you can make types very strongly defined.
type type MethodType = "GET" | "PUT" | "POST" | "DELETE"
MethodType = 'GET' | 'PUT' | 'POST' | 'DELETE';
function function makeHttpCall(url: string, method: MethodType): void
makeHttpCall(url: string
url: string, method: MethodType
method: type MethodType = "GET" | "PUT" | "POST" | "DELETE"
MethodType) {
var console: Console
console.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)log(`Im hitting ${url: string
url} with ${method: MethodType
method}`);
}
const const url: "johns.codes"
url = 'johns.codes';
function makeHttpCall(url: string, method: MethodType): void
makeHttpCall(const url: "johns.codes"
url, 'GET'); // allowed
function makeHttpCall(url: string, method: MethodType): void
makeHttpCall(const url: "johns.codes"
url, 'GeT'); // not allowedfunction makeHttpCall(url: string, method: MethodType): void
makeHttpCall(const url: "johns.codes"
url, 'POG'); // not allowed
This helps greatly for new users of this function to see what the valid method fields are without having to look at external documentation, your editor will provide autocomplete on the method field, and you get a compile error if you try to use an arbitrary string as the method parameter.
Restricting Unions
Literals allow for strongly typed APIs, but how do you properly narrow a general type to a more specific type? Typescript allows this in a few ways
function function handleAny(url: string, method: unknown): void
handleAny(url: string
url: string, method: unknown
method: unknown) {
if (typeof method: unknown
method === 'string') {
// in this block method is now a string type
if (method: string
method == 'GET') {
// method is now the literal "GET"
function makeHttpCall(url: string, method: MethodType): void
makeHttpCall(url: string
url, method: "GET"
method);
}
if (method: string
method == 'PUT') {
// method is now the literal "PUT"
function makeHttpCall(url: string, method: MethodType): void
makeHttpCall(url: string
url, method: "PUT"
method);
}
}
}
This manual checking is fine but if you have a more complex type or a union with many possible values this gets unwieldy quite fast. The next best approach is a type predicate
// First define valid methods as a const array
const const ValidMethods: readonly ["GET", "PUT", "POST", "DELETE"]
ValidMethods = ['GET', 'PUT', 'POST', 'DELETE'] as type const = readonly ["GET", "PUT", "POST", "DELETE"]
const;
type type MethodType = "GET" | "PUT" | "POST" | "DELETE"
MethodType = typeof const ValidMethods: readonly ["GET", "PUT", "POST", "DELETE"]
ValidMethods[number]; // resulting type is the same as before
function function isValidMethod(method: unknown): method is MethodType
isValidMethod(method: unknown
method: unknown): method: unknown
method is type MethodType = "GET" | "PUT" | "POST" | "DELETE"
MethodType {
// need the `as any` since valid methods is more strongly typed
return typeof method: unknown
method === 'string' && const ValidMethods: readonly ["GET", "PUT", "POST", "DELETE"]
ValidMethods.ReadonlyArray<"GET" | "PUT" | "POST" | "DELETE">.includes(searchElement: "GET" | "PUT" | "POST" | "DELETE", fromIndex?: number): boolean
Determines whether an array includes a certain element, returning true or false as appropriate.includes(method: string
method as any);
}
function function handleAny(url: string, method: unknown): void
handleAny(url: string
url: string, method: unknown
method: unknown) {
if (function isValidMethod(method: unknown): method is MethodType
isValidMethod(method: unknown
method)) {
// method is now a MethodType
function makeHttpCall(url: string, method: MethodType): void
makeHttpCall(url: string
url, method: "GET" | "PUT" | "POST" | "DELETE"
method);
}
}
The type predicate isValidMethod
is just a function that returns a boolean, when true Typescript knows the input parameter method
is a MethodType
and can be used as such. Type predicates are a good simple way to encode any runtime checks into the type system.
Discriminated unions
Now unions of basic literals are quite powerful, but unions can be even more powerful when you make unions of objects. Say in your app you track different events. The events could look like the following
interface LoginEvent {
// the user's email
LoginEvent.user: string
user: string;
LoginEvent.wasSuccessful: boolean
wasSuccessful: boolean;
}
interface PostCreatedEvent {
PostCreatedEvent.name: string
name: string;
PostCreatedEvent.body: string
body: string;
PostCreatedEvent.createdAt: Date
createdAt: Date;
}
// and many others
Once you have typed out all the different events, and you want to group them together to a single event type you might think a simple union like type ApiEvent = LoginEvent | PostCreatedEvent | ...
would be good but when you want to narrow this type down you would have to end up with a lot of if ('user' in event) {..}
checks or many custom type predicate functions.
To avoid that issue you can define the event types as a Discriminated union. All this is, is a union type where all types in the union have a field whose value is unique in all the union's types. We can redefine the above types as follows
interface LoginEvent {
LoginEvent.type: "login"
type: 'login';
LoginEvent.user: string
user: string;
LoginEvent.wasSuccessful: boolean
wasSuccessful: boolean;
}
interface PostCreatedEvent {
PostCreatedEvent.type: "postCreated"
type: 'postCreated';
PostCreatedEvent.name: string
name: string;
PostCreatedEvent.body: string
body: string;
PostCreatedEvent.createdAt: Date
createdAt: Date;
}
type type ApiEvent = LoginEvent | PostCreatedEvent
ApiEvent = LoginEvent | PostCreatedEvent;
type type EventTypes = "login" | "postCreated"
EventTypes = type ApiEvent = LoginEvent | PostCreatedEvent
ApiEvent['type']; // this resolves to 'login' | 'postCreated'
In this example you could name the key type
whatever you want, as long as every type has that field the union type will allow you to access the key. Now to narrow this type down you could do the following
function function logEvent(event: ApiEvent): void
logEvent(event: ApiEvent
event: type ApiEvent = LoginEvent | PostCreatedEvent
ApiEvent) {
if (event: ApiEvent
event.type: "login" | "postCreated"
type === 'login') {
var console: Console
console.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)log(`user: ${event: LoginEvent
event.LoginEvent.user: string
user}, wasSuccessful: ${event: LoginEvent
event.LoginEvent.wasSuccessful: boolean
wasSuccessful}`);
} else if (event: PostCreatedEvent
event.PostCreatedEvent.type: "postCreated"
type === 'postCreated') {
var console: Console
console.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)log(`post ${event: PostCreatedEvent
event.PostCreatedEvent.name: string
name} was created at ${event: PostCreatedEvent
event.PostCreatedEvent.createdAt: Date
createdAt}`);
}
}
This style of checking the discriminating field in if statement is fine but is a little verbose to me. I find that a switch statement makes it more readable and less verbose.
function function logEvent(event: ApiEvent): void
logEvent(event: ApiEvent
event: type ApiEvent = LoginEvent | PostCreatedEvent
ApiEvent) {
switch (event: ApiEvent
event.type: "login" | "postCreated"
type) {
case 'login':
var console: Console
console.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)log(`user: ${event: LoginEvent
event.LoginEvent.user: string
user}, wasSuccessful: ${event: LoginEvent
event.LoginEvent.wasSuccessful: boolean
wasSuccessful}`);
break;
case 'postCreated':
var console: Console
console.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)log(`post ${event: PostCreatedEvent
event.PostCreatedEvent.name: string
name} was created at ${event: PostCreatedEvent
event.PostCreatedEvent.createdAt: Date
createdAt}`);
break;
default:
throw new var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error(`invalid event type: ${(event: never
event as { type: string
type: string }).type: string
type}`);
}
}
There is one issue with this approach, in the future when we add a new event type it would fall through to default case, and we wouldn't know about it until runtime. However, using Typescript's never
type we can force a compile error when we don't handle all cases
function function assertUnreachable(type: never): never
assertUnreachable(type: never
type: never): never {
throw new var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error(`Invalid event type: ${type: never
type}`);
}
function function logEvent(event: ApiEvent): void
logEvent(event: ApiEvent
event: type ApiEvent = LoginEvent | PostCreatedEvent
ApiEvent) {
const const type: "login" | "postCreated"
type = event: ApiEvent
event.type: "login" | "postCreated"
type;
switch (const type: "login" | "postCreated"
type) {
case 'login':
var console: Console
console.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)log(`user: ${event: LoginEvent
event.LoginEvent.user: string
user}, wasSuccessful: ${event: LoginEvent
event.LoginEvent.wasSuccessful: boolean
wasSuccessful}`);
break;
case 'postCreated':
var console: Console
console.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)log(`post ${event: PostCreatedEvent
event.PostCreatedEvent.name: string
name} was created at ${event: PostCreatedEvent
event.PostCreatedEvent.createdAt: Date
createdAt}`);
break;
default:
// event.type is `never` here since this default case would never be hit since all possible cases are handled
function assertUnreachable(type: never): never
assertUnreachable(const type: never
type);
}
}
Now in the future if we added an event with a type field of NewEvent
it would fall through to the default case, since its type is not never
(it would be NewEvent
) we would get a compile error on the call to assertUnreachable
.
Wrap up
While these features I covered can help you a lot (these are almost all I used during the initial typescript migration), there are many other really cool typescript features, like generics, mapped types and conditional types. I hope to cover them all in a Part 2 so check back soon!