Welcome to the article I wish I had when I started tinkering with conditionals. This write-up might be overly detailed. But you know what? This is my blog. So I can do whatever I want. Let’s get to it.
What’s a conditional type?
The syntax of a conditional might remind you of a ternary operator in javascript (hint: its not).
The following conditional type always evaluates to true, because it checks if “Superman” is of type string. And of course, it is:
type IsString = 'Superman' extends string ? true : false; // true
But what happens if we turn the “comparison” around?
type IsString = string extends 'Superman' ? true : false; // false
If you’re thinking about a conditional like a ternary, this might be confusing. Because you might think that it’s an equality check (string === 'Superman'
). And 'Superman'
is a string
, so why does this evaluate to false
?
It’s confusing because a conditional is not an equality check. So, what is the case?
Easy: the extends
keyword checks if the left side of extends
is assignable to the right side.
But.. what does that even mean?
Assignability in Typescript
Think of a function. Let’s say we have a function that takes a string as an argument:
function greet(name: string) {
console.log(`Hello ${name}`);
}
We can call this function with any string. Typescript will check if the argument is assignable to the type string
:
greet('John'); // 'Hello John'
greet('Alice'); // 'Hello Alice'
In this case, 'John'
and 'Alice'
are assignable to the type string
.
Let’s refactor our function. But now it takes a string literal 'Bart'
as an argument.
function greet(name: 'Bart') {
console.log(`Hello ${name}`);
}
greet('Bart'); // ✅ 'Hello Bart'
greet('Angel'); // ❌ Error: Argument of type '"Angel"' is not assignable to parameter of type '"Bart"'
const name: string = 'Justin';
greet(name); // ❌ Error: Argument of type 'string' is not assignable to parameter of type '"Bart"'.(2345)
We can only call it with 'Bart'
. And nothing else. That’s because 'Bart'
is the only thing that’s assignable to the type 'Bart'
.
This principle is also applicable to conditional types. They check if the left side is assignable to the right side.
type IsString = 'Superman' extends string ? true : false; // true
type IsString = string extends 'Superman' ? true : false; // false
Types are sets of values
In order to understand how the concept of assignability really works, we need to look at types as sets of possible values.
'hello'
is assignable to string
. In fact, every string literal value is assignable to string
. You could say that the type string
is an infinite set of all string literals.
But string
is not assignable to 'hello'
. That’s because the type 'hello'
is a set of one string literal. Only the string literal 'hello'
is assignable to the type 'hello'
.
Object assignability in Typescript
Although at first glance a bit weird, the same goes for objects. An object type is an infinite set. We can assign every object value to it, as long as it adheres to the properties explicitly described!
type Movie = { title: string };
function printMovie(movie: Movie) {
console.log(movie.title);
}
const movie = { title: 'Superman' };
printMovie(movie); // ok
const movie2 = { title: 'Superman', yearReleased: 1978 };
printMovie(movie2); // still ok!
const movie3 = { villain: 'Lex Luthor' };
printMovie(movie3); // ❌ Error: Argument of type '{ villain: string; }' is not assignable to parameter of type 'Movie'.
Even with extra properties (yearReleased
), we can still assign it to type Movie
. In fact, we can assign every object to Movie
as long as it has a title
of type string
.
We can translate that concept to a conditional as well:
type IsMovie = { title: 'Superman'; yearReleased: 1978 } extends { title: 'Superman' }
? true
: false; // true
If that’s confusing, don’t worry.
I’ve written extensively about this concept, regarding types as sets of values (and structural typing) in my article: Understanding The Intersection.
Use a conditional type in an example
Now that we’ve got assignability down, let’s make it bit more lively, and create a example.
We’ll stick to our first example, where we want to check if a certain input is a string or not. Our runtime function will look like this:
function isString(value: unknown) {
return typeof value === 'string';
}
const result = isString('Angela');
// ^? boolean
This returns a boolean, because Typescript is not smart enough to determine the outcome for itself. But we want it to return true
(or false
). We’re going to need some type magic for that. Let’s refactor our code:
type IsString<T> = any; // TODO: the conditional
function isString<T>(value: T) {
return (typeof value === 'string') as IsString<T>;
}
A type predicate would be an easier solution, but the goal is to learn conditional types here!
We’ll add a type parameter, because Typescript needs to know what value we’re working while its type checking. We need to assert the return type with as
to the IsString
type, because Typescript can’t infer it by itself.
We’ve written the conditional type IsString
before but now we’ll make it generic:
type IsString<T> = T extends string ? true : false;
This will check if T
(left) is assignable to a string
(right). Let’s put it all together:
type IsString<T> = T extends string ? true : false;
function isString<T>(value: T) {
return (typeof value === 'string') as IsString<T>;
}
const result1 = isString('Angela'); // true
const result2 = isString({ name: 'Angela' }); // false
const result3 = isString(32); // false
And it works! Congratulations. You’ve created (and implemented) your first conditional type! 🎉
An example with an object
Since it might be a bit unintuitive, let’s do an example for objects as well.
We want to see if an object has a value
property. That’s it. We don’t care about the type of the value, we just want to know if it’s there. Our runtime code will look like this:
function hasValueKey(obj: Record<string, unknown>) {
return 'value' in obj;
}
const result = hasValueKey({ value: 'Superman' });
// ^? boolean
Again, we want to have a true
or false
return type.
type HasValueKey<T> = any; // TODO
function hasValueKey<T extends Record<string, unknown>>(obj: T) {
return ('value' in obj) as HasValueKey<T>;
}
We can only use the in
operator on objects. So we have to make sure that Typescript knows that obj
is an object. That’s what the constraint Record<string, unknown>
is for.
Now, let’s write our conditional type.
type HasValueKey<T> = T extends { value: unknown } ? true : false;
This will check if T
is assignable to an object with a value
key. We need this because an object without a value
key wouldn’t be assignable to { value: unknown }
.
// This will check if { value: 'Superman' } is assignable to { value: unknown }
type HasValueKey = HasValueKey<{ value: 'Superman' }>; // true
// This will check if { name: 'Clark Kent' } is assignable to { value: unknown }
type HasValueKey = HasValueKey<{ name: 'Clark Kent' }>; // false
Perfect! Now let’s put it all together:
type HasValueKey<T> = T extends { value: unknown } ? true : false;
function hasValueKey<T extends Record<string, unknown>>(obj: T) {
return ('value' in obj) as HasValueKey<T>;
}
const result1 = hasValueKey({ value: 'Superman' }); // true
const result2 = hasValueKey({ value: 'Superman', name: 'Clark Kent', age: 32 }); // true
const result3 = hasValueKey({ name: 'Clark Kent' }); // false
It works! We’ve used a conditional type to check if an object has a value
key.
Conclusion
Hopefully you’ve learned about assignability in Typescript, and how it’s used in conditional types. Next steps are learning about the infer
keyword (and how we can use it to extract types from other types), and distribution of conditional types.