Introduction
The reason why Typescript and Rust has been steadily gaining adoption is the superiority of their type systems compared to conventional programming languages like Java or Python. One thing that stands out about their type systems is their ability to do pattern matching.
In this post I'll give you an overview of what pattern matching is, how Typescript's switch
statement works and its drawbacks. and I'll compare and contrast Rust pattern matching mechanics with Typescript's.
What is Pattern Matching?
Pattern matching is a mechanism that is used to check if a value matches a particular pattern defined within a programming construct. Pattern matching is useful because we can succinctly check if a value has a certain set of properties without having to use multiple if-statements. Multiple if-statements are bad because they are verbose and you have to worry about dealing with nullable nested fields (i.e. checking if the property is null before checking the value).
Pattern matching is also useful for doing state management because it forces you to add a handler all the possible states that your type allows. If your type was a discriminated union, then your handler would handle all variants of the union. This prevents logic errors from creeping up into your programs because you can't accidentally forget to a handle a state.
Pattern matching is also commonly associated with algebraic data types (ADTs) because ADTs can be decomposed into its constituent parts using pattern matching.
Lets look at some examples of pattern matching.
Example 1 — The Switch Statement
The only way to pattern match in Typescript is with the switch statement. Given a type that is a discriminated union, we can narrow the type using its discriminator.
For example in Redux, we use switch statements to match against different types of actions to update the state. The type of action is defined by its discriminator property type
.
type Todo = {
readonly id: string
readonly title: string
readonly description: string
}
type AddTodoAction = {
readonly type: 'ADD_TODO'
readonly todo: Todo
}
type RemoveTodoAction = {
readonly type: 'REMOVE_TODO'
readonly id: string
}
type Action = AddTodoAction | RemoveTodoAction
type State = {
readonly todos: ReadonlyRecord<string, Todo>
}
function reducer(state: State = { todos: {} }, action: Action): State {
switch (action.type) {
case 'ADD_TODO':
return { todos: { ...state.todos, [action.todo.id]: action.todo } }
case 'REMOVE_TODO': {
const { [action.id]: _, ...nextTodos } = state.todos
return { todos: nextTodos }
}
}
}
As evident in the example above, the type of action
will be narrowed down to AddTodoAction
when the type
matches ADD_TODO
and similarly narrowed down to RemoveTodoAction
when the type
matches REMOVE_TODO
.
Drawbacks of Switch Statements
1. Switch statements are verbose
When you try to pattern match against nested ADTs, it adds verbosity because each level of nesting requires another switch state.
For example, the code snippet below is verbose because it uses two switch statements to decompose the Option<FooBar>
type.
type Option<A> = { _tag: 'Some'; value: A } | { _tag: 'None' }
type Foo = {
_tag: 'Foo'
}
type Bar = {
_tag: 'Bar'
}
type FooBar = Foo | Bar
declare const foobar: FooBar
switch (foobar._tag) {
case 'Some':
// Double switch! 🤮
switch (foobar.value._tag) {
case 'Foo':
break
case 'Bar':
break
}
break
default:
break
}
Alternatively you can introduce a second function and move the switch statement into it. This removes the nesting but still adds verbosity.
function handleFooBar(foobar: FooBar) {
switch (foobar._tag) {
case 'Foo':
break
case 'Bar':
break
}
}
switch (foobar._tag) {
case 'Some':
handleFooBar(foobar.value)
break
default:
break
}
2. Switch Statements Cannot be Assigned to Variables
If you want to assign the result of a switch statement to a variable, you need to wrap into a function, which adds verbosity.
// Very verbose! 😡
const fibonacci = ((n: number) => {
switch (n) {
case 0:
return 0
case 1:
return 1
default:
return n + n - 1
}
})(100)
3. Discriminators can only be primitive fields
Third, you can only switch on primitive fields: i.e. string
and number
. Switching on an object does not work.
const foo = { bar: 'baz' }
switch (foo) {
case { bar: 'baz' }:
console.log('foobarbaz') ❌
break
default:
console.log('nope.jpg') 😢
break
}
From a pattern matching perspective, Typescript is very limited in what it can do. Hence, this makes writing functional code more verbose and requires more boilerplate because you'll need to write your own matchers. See ts-adt as an example.
Example 2 — The Match Statement
In Rust, we don't have switch statements, we have match statements. Match statements are like Typescript switch statements but without all the drawbacks.
1. Match statements are concise
Here's an example of same redux code but written in Rust; written mutably for extra brevity.
use std::collections::HashMap;
struct Todo {
id: String,
title: String,
description: String
}
enum Action {
AddTodoAction { todo: Todo },
RemoveTodoAction { id: String }
}
struct State {
todos: HashMap<String, Todo>
}
impl Default for State {
fn default() -> State {
State { todos: HashMap::new() }
}
}
fn reducer(state: &mut State, action: Action) {
match action {
Action::AddTodoAction { todo } => {
state.todos.insert(todo.id.clone(), todo);
},
Action::RemoveTodoAction { id } => {
state.todos.remove(&id);
}
}
}
How is this more concise?
First, because Rust is nominally typed rather than structurally typed, so we don't need a type
discriminator field.
Second, we leverage Rust's enum syntax to destructure fields directly in a match statement as demonstrated with Action::AddTodoAction { todo }
.
2. No Nested Matches
The same foobar example shown in the Typescript example can be written in Rust like so:
pub enum FooBar {
Foo,
Bar
}
fn main() {
let foobar: Option<FooBar> = Some(FooBar::Foo);
match foobar {
Some(FooBar::Foo) => {}
Some(FooBar::Bar) => {}
_ => {}
}
}
You can see how advantageous this is compared to Typescript because you can just construct the values you want to match against directly as demonstrated by Some(FooBar::Foo)
.
Heres a more complicated example where we match against certain fields and ignore the fields we don't care about using the ..
syntax. The ..
syntax is analogous to Typescript's ...
.
struct Cube {
length: u32,
width: u32,
height: u32
}
fn main() {
let my_cube = Cube {
length: 10,
width: 5,
height: 5
};
match my_cube {
// only match cubes of length 10
Cube { length: 10, .. } => {},
_ => {}
}
}
You can also name the parameters you match.
Cube { length: cube_length @ 10 , .. } => {
println!("{}", cube_length); // cube length is a variable
},
And if you use an unstable feature, you can match the cube length against a range as well.
#![feature(exclusive_range_pattern)]
// only match cubes of length (10-15]
Cube { length: cube_length @ 10..15 , .. } => {
println!("{}", cube_length);
},
Example 3 — The If-Let Statement
In the previous example we wrote a full match
statement to match against a very specific variant of a cube but we discarded the other variants.
If we don't care about other variants, then a succinct way of pattern matching is using an if-let
statement.
if let Cube { length: cube_length @ 10, .. } = my_cube {
println!("{}", cube_length);
}
This makes writing pattern matching code idiomatic.
There are problems though; you can't chain if-let
statements with conditionals.
let my_cond = true;
// ❌ Syntax Error
if (let Cube { length: cube_length @ 10, .. } = my_cube) && my_cond {
println!("{}", cube_length);
}
But you can just add the conditional into a tuple and pattern match against the tuple.
if let (Cube { length: cube_length @ 10, .. }, true) = (my_cube, my_cond) {
println!("{}", cube_length);
}
Example 4 — Rust: Matching Slices
In Rust, not only can you match against structs and enums, but you can also match against slices.
Here is a basic example matching against an empty slice.
if let [0,0,0] = [0; 3] {
println!("woah");
}
A more sophisticated example is matching against a vector with a known size.
let my_vec = vec![1,2,3];
if let &[1, x, 3] = &*my_vec {
println!("{}", x); // 2
}
Note, dereferencing my_vec
with *
turns the vector into a slice. The slice on the right then needs to be borrowed and matched against a borrowed slice on the left. This is because an owned slice is not sized and will fail compilation.
For vectors where we don't know the size we can replace the parts we don't care about matching with ..
.
let my_vec = vec![1,2,3,4,5];
if let &[1, .., 4, 5] = &*v {
println!("wow!");
}
The caveat is we can only match the head and the tail, we can't use the ..
operator more than once. For example, this won't compile.
if let &[1, .., 3, .., 5] = &*v {
println!("this doesn't compile!");
}
Conclusion
You should now understand the differences between Typescript and Rust pattern matching. Rust lets you do advanced pattern matching while Typescript is limited to basic switch statements and discriminated unions. The takeaway is the more complex your types are, the more you should consider languages like Rust, or even Scala because of their advanced pattern matching techniques.
Thanks for reading! If you enjoyed this post, please consider sharing.
Top comments (0)