DEVWorld 2026

Rijk van Zanten - Rust-Inspired Typescript

29:51 · 07 May 2026 – 08 May 2026 · YouTube

About this talk

In this talk, Rik van Velzen discusses the interplay between TypeScript and Rust, sharing insights from his extensive experience with TypeScript while working on the Directus project. He emphasizes how TypeScript allows flexibility but can lead to unexpected software states, akin to a 'food gun.' The session explores various strategies to improve TypeScript coding practices by adopting principles from Rust, such as managing states with discriminated unions, handling expected failures through explicit return types, and enhancing boundary validation. Van Velzen also highlights the importance of keeping mutations local in code to maintain clarity and prevent unintended side effects. Ultimately, he encourages developers to embrace strictness in TypeScript to create more robust and maintainable codebases.

Full transcript

[singing and music] >> All right. Hello everyone. Quick sound check. Time to put those cans on. How we doing? All right. All right. The cans are coming on. That's good. Can all hear me fine? That's a bit surreal, but all right. No echo. That's good, too. Um all right. So, before we dive in, as per usual with these, if you've done many before, quick quick show of

hands, just so I know who's in the room here with us. Um given that this is the JavaScript track of this particular conference, I think this first one is a bit of a doozy, but any any TypeScript is in the room right now? I mean, I'm I know I'm one of them. Oh, this is going to be a support group now, I think. All right. Um kind

of expected that, of course, given what this is. Second one? We're doing strict? Well, a little bit less, but all right. All right. Couple of cheats in here. That's good. Now, even with that strict mode enabled, though, uh raise or keep it raised. Do you sneak in an as any every now and again? Couple of custom types I'm seeing Oh. Oh. Okay. Yeah. Round of applause for

yourself, I hear from next door. Who's regretted that after the fact? A Same same group. All right. Very good. Yeah, definitely me as well. Definitely Um I'm Rik van Velzen, Dutch-born and raised, product designer and software engineer. Um work on Directus nowadays as a headless CMS, Vue-based studio, TypeScript-based APIs. Um I'm also a part-time professor at the Parsons School of Design in Typography and Interaction. So, all

of that means that I spend a lot of my time thinking about that overlap of human interaction and software. And with that, also software development at scale. Now, over the lifetime of the Directus project, which for me is like the last 9 years or so, I've written over a quarter million lines of TypeScript by hand, mind you. This is before they generate AI days. Um and kind

of have the scars to back it up. I think one specifically. Um now, that much TypeScript, it teaches you quite a few things. Teaches you what the language is great at, but it also teaches you where a team can really ship more stable software. It also taught me though that TypeScript will very politely let you build a very, very large food gun and then hand that over

to the next person in the review cycle. Now, in the last year and change at Directis, we've also actively been working on a new product that has been written in Rust. So, I've been spending quite a lot of time on sort of both sides of the house now, right? So, we have that very large TypeScript production code base that's been out in the wild and been used

by tens of thousands of projects and a new repo in Rust that required me to get familiar with its own sort of ways of thinking about correctness and ownership and errors and tooling around it. Now, Rust itself has already changed the JavaScript ecosystem both visibly and invisibly. You might have seen, you know, some members around Oxylint and Oxform and projects like that cuz a lot of tooling

like bundlers and linters and formatters and CLIs, native bridges, etc., etc. in the JavaScript ecosystem are starting to move towards Rust to get the most out of the performance and the resource use of your program. We're also starting to see it more and more now for like API development, which is where we come in as well. Now, even though Rust can be substantially harder to work with

than TypeScript, especially when it comes to like memory management and borrowing and the ownership system, usually the performance and and resource benefit is worth the effort at the level of skill where that where that becomes really important. Now, that being said, this is not TypeScript versus Rust. Don't [laughter] Don't kick me out quite yet. Um not here to convince you to drop TypeScript by any means. Not

here to convince you that Rust is better even. Um you know, as with everything else, this is very much a question of what is the right tool for the right job. And then, you know, as of this day and age, TypeScript is still great for a lot of these jobs. So, what we'll look at today here is really how we can improve our own TypeScript projects by

learning a thing or two from the intricacies of the Rust language. usually when people talk about Rust generally, the conversation often jumps to performance and memory safety, the ownership and the borrow checker, and some more comp sci-y things like that. Um those are very important, but they aren't necessarily topics we can just learn from one to one in a TypeScript context as we can't just magically replace

the runtime. At the end of the day, we're still writing JavaScript that needs to run somewhere. Um where we can learn, however, from Rust is all around that strictness and constraints, right? So, Rust is strict about a lot of stuff like with types, the state management, failures, mutations, the compiler is generally quite quite strict. And, you know, TypeScript has helped us all to bring some of that

strictness and some of those constraints back into a JavaScript code base, but it can't always be waterproof. At the end of the day, the TypeScript part still has to be stripped out and you have regular JavaScript at the end of the day. So, therefore, you never have that full security and that safety of what actually happens in in your variables. The way I think about the strictness

and constraints here has has really changed during the development of this new project. I I have to admit early on, these constraints felt just like I constantly were fighting the language, right? I constantly had to figure out like, oh, I have to make a new slightly play it and all of a sudden I'm just constantly fighting this compiler trying to get it to work, but as our

team scaled and as this project came along, I also figured out, you know, those constraints just really help our team collaborate on that code base cuz it takes a lot of the assumptions out of people's heads heads and into the code itself, into the type signatures. while TypeScript can't really do that in the same extent, we can still use some of the tricks from that in TypeScript

by using the TypeScript language a little cleverer. So, for today, I've identified four practical Rust habits and a little honorable mention that we can use to make our own TypeScript better. Uh none of these will require you to write, you know, rewrite your whole stack or or, you know, do do major refactors. Um they can all be incrementally adopted. So, first and foremost, we'll start with impossible

states. Uh most of the production bugs in directives as well have a very boring shape. You know, the UI got into a state that nobody expected or the back end accepted a combination of fields that we really didn't intend. Um or um workflow moved ahead before a certain prerequisite was was true. Or loading states and error states are true at the same time and now you don't

know if you need to show a spinner or an error message or both. Now, these bugs rarely look impressive in hindsight cuz they kind of just look like this. I mean, I'm sure you might have written something like this before. Um this type looks very flexible because it is. And that's the problem. It allows, you know, in this example for checkout states, it allows a state to

exist that is both loading and filled and paid all at the same time. It allows a receipt to exist without a cart or it allows an error next to a successful payment. All of these different versions of this state are permutations that we don't want and didn't account for uh and then intend. It allows every state you wanted and needed, but also a bunch of new states

that you don't want and did not account for. Now, Rust's enum model gives us a little bit of a better habit for managing this level of state. Um in Rust, enums work very differently from the ones you're used to seeing in TypeScript. So, you know, leave it or or just feel free to forget the terminology around it, but at the end of the day, each individual enum

variant in Rust can hold its own data with its own shape. So, for example, on the Rust side of the house, that might look like something like this. So, we have one enum with a bunch of different variants, but as you can tell, the ready variant has and a total amount of money, whereas the filled variant only has a cart and an error state. In a Rust

enum, by definition, the checkout state then is only ever one of those variants at the same time. Now, in TypeScript, an enum is basically just a name string constant, so you can't really use them the same way here. But, we can achieve that same effect with a discriminated union types. Excuse me. So, for example, in TypeScript, we might model it something like this. We have a single

type, which is effectively just five objects in a trench coat, um but these different variants give us very similar protections against unexpected states. If the checkout is empty, there cannot be a cart. If the checkout failed, you can reliably assume there is an error. If it's paid, there is a receipt, etc. But, you can never have a receipt for a filled checkout. The type itself now documents

this, and more importantly, it will limit the workflows that you use these types for, as you cannot accidentally create a value that is both submitted and paid. Uh unless you go out of your way, of course, in TypeScript to do a couple of as any and all that kind of stuff. the second half then of this particular pattern is how you use that type to be exhaustive.

So, the union gives us um the set of valid states that we account for in the program, but it doesn't automatically make sure that all of the different states have been handled elsewhere. So, let's say we're rendering a little message to the screen to show the user what the current state of the cart is. Might be just a little regular string. Uh you might currently do something

like this, right? You have a switch state or or a bunch of if else's that just say, "Okay, what is the current state?" If it's empty, we're going to show your cart is empty. If it's ready, we're going to say, "Well, you're ready to pay the X amount total from your cart." Right? When using a switch statement, there is a way to have TypeScript already warn you

about, you know, forgotten default states with the implicit no implicit return setting. With that enabled, you know, you might add some default for the unknown case, or you might even throw an error being like, "Hey, we've hit a state that we didn't expect. We're going to throw an error instead, so you can handle it elsewhere." However, there is a way where we can just have TypeScript show

this error in build time. So, you know that you forgot to handle one of these different state options. Um which really brings it in line more with with the folks familiar with Rust, how you can do matches against the result types. Um and we can we can sort of work that in with the never type. So, by returning the never type in the default flow, any of

the state shapes that haven't been handled yet in this union will become a build time error in TypeScript instead of a runtime error. So, that also then allows us to simplify the review step a little bit, cuz if the default state is a build time error, it also means that if TypeScript's types check passes, you know that all of the different states have been appropriately handled. So,

that is the first of four. That's impossible states. Anytime you see a type with a few booleans and a handful of optional fields, there's a decent chance you end up looking at a hidden state machine. So, make those states explicit. Make the invalid combinations awkward to use. Next up, explicit failures. So, in TypeScript, functions are generally typed and written for the happy path. It's quite common to

see a function like this, right? The function type signature says, "You give me a customer and I give you a receipt." Um but in runtime then, that is all but a guarantee. Maybe that customer doesn't have a card on file, or maybe the payment provider declines to charge for some reason, or the payment provider might be down for some other reason. I don't know if you saw,

but the other day I think all of the .de domain just disappeared from the internet. Problem, right? How do you How do you handle that? So, in Rust, expected and more importantly recoverable errors like that are usually handled as an explicit return type of the function itself, rather than this invisible exception path that we're used to in TypeScript and and JavaScript. So, instead of having the type

signatures just for that happy path like we see here, again, you know, customer and amount goes in, receipt comes out, we can account for all of these expected failure branches as well. So, in this example here, this is how you would see it in Rust. The return to Again, feel free to, you know, ignore some of the the Rust intricacies, but the important part here is that

you have a function with a result type as the output, rather than just the receipt itself as the output. Meaning that we either return the receipt when it was a successful call, or we return a payment error when it isn't. And that payment error here is just an enum like we saw in that previous example, right? In Rust, there is some some nicety, some syntactic sugar here

that we can use to make that return a little bit easier. So, the question mark at the end of that first line within the function means that if that line returned an error, just short-circuit and and return the error and bubble it up. But it's it's basically just the Rust's way of failure saying that, you know, just return the error from the function if it failed. That

also that means that those failures will still short-circuit like you might have seen in TypeScript before. Again, if you throw an error, the rest of the function doesn't run, right? That's That's what we want. It just happens here through the type system instead of through that exception flow. Now, back in our TypeScript version here, we can achieve a quite similar effect by approaching this with a result

type output that is returned instead. So, note how in this example, we're using the discriminated unions again on that result type to say, "Okay, we have two different states and they don't overlap. If it is successful, you You we have a value. If it's not successful, we have an error. You can never have both and the different errors in the payment error discriminated union might also have

different context. So, a missing card error here will have information about the customer that the card was missing for, whereas just a generic declined error that comes from the payment provider potentially has a reason string, right? But never both. Again, avoiding that that optionality there. Um once the failure states are explicit like this, a lot of the review questions that would otherwise happen after the fact are

already answered ahead of time, right? Cuz without a result type in place here, you always have to ask yourself, can this fail? And if so, how? And when it does fail and those errors get thrown up, like where is that being handled? Is there a try catch, you know, in the parent caller? Or is there some sort of other middleware in your API if you're building an

endpoint that is waiting for those errors to come in? exceptions in general, both in Rust and TypeScript, are still fine. You know, they still exist for exceptional cases. Um if there is a programmer error, you just literally just wrote something that doesn't work. Uh or an invariant that's violated. But generally speaking, for functions that you expect a normal caller to be able to recover from, we should

try using return times a little bit more rather than just uh throwing errors. Now, to make this a bit easier, uh lucky for me for this talk, Dylan Milbro over at Cloudflare recently launched a package that basically implements a lot of the niceties from Rust in a package you can just install and and use out of the box uh called better result. So, it it gets you

some more ergonomic, you know, helper functions around returning and handling some of these error types. Um here's just a a snippet of what that might look like, right? So, you see that match statement all the way at the bottom, or you know, a helper for checking if something is okay. Uh another library that implements this is effect, um similarly very helpful. Uh give them a shot. Give

him a shot. Cool. All right. So, that was explicit failures. I know, you know, we're about halfway through here and we still halfway to go. So, bear with us we speed up slightly. Um [snorts] so far here, we've been looking at internal code. So, these are types that I define. I know what to expect as a developer and then I can handle them better. But, in real

life, modeling those types honestly only really works when you already know all of the different types that are flowing through your system, right? If I'm the one defining an object, I know what that type is and how to handle it. Um That being said, you know, that is where TypeScript really shines. But, where it gives us the weakest sort of safety is around the edges, right? Where

a a JSON response from an API needs to be used or a form gets submitted, SDKs are being used, etc. etc. So, next up, let's take a look at that boundary validation. The sort of parse don't validate approach if you've heard it Now, TypeScript can give us a lot of false confidence if we're not careful with this one. So, again, you might have might have done something

like this in the past. Based on the show of hands, I know there's at least a good quarter of you guys that that just like me have done this before. You might pull something out of an API and just be like, "Oh, yeah, that's a card read response." And then from that point forward since TypeScript, it is, right? Cuz you tell it to trust you and you

tell it that that's what it is. I know I've done something like this. I I know that some of us have also then subsequently, you know, blamed the back-end team for breaking breaking your mess. I know I have at least. Um but, there's there's two problems hiding in here, right? First, that as card read response, it doesn't validate anything. So, it just tells the compiler to trust

you and take that unknown value at face value for this uh pseudo card read response. And secondly, there's actually no way to know if the product ID that is passed into that that product function is actually a product ID, right? It's just a string. So, let's start making this a little bit better like a crustacean. Um we'll start with a product ID type boundary cuz it's a

little bit simpler to to walk through it. We can start by using an an alias type. So, that makes it quite explicit for your human reviewers. Okay, I don't expect just any old string. I expect a product ID. Um, it's a little bit better cuz it makes the intent clear. But, it doesn't give you any build or run time security around it as any string by default

is still a valid Something like this will compile just fine even though nothing has all nothing at all has proven that that particular string is a valid product ID. So, in Rust it's quite common to make dedicated new types for cases like this. Warning ahead of time, this is a lot of boiler platey stuff. So, you're welcome to forget most about most of it. Um, the important

bit here is that you wrap that primitive value, in this case a string, in a domain specific type. So, a product ID itself will contain a string, but not every string is a Um, that in turn means that our our uh get product function can take an actual product ID domain type rather than just And therefore, you get the compile time security that that is always uh

handled appropriately. If all you have is a raw string from the request like we have here in that raw ID line, you first have to construct the domain value out of it before you can then pass that domain value over to your next function. Uh, note how this is using a result type again. It's because of the validation. Not every single string is by definition a product

ID. In this case, you know, we check if it starts with PRD. You might have seen this with API keys before, right? Where there's SK-whatever whatever. Uh, we're doing the same here. Now, we can do something similar in TypeScript. Again, it's a little boiler plate heavy, I'll admit, but it it does help at the end of the day by relying on classes. We can say external data

enters as an unknown. We validate it once. And from that point onwards, we use this class moving forwards as a sort of custom object type. Um, the private constructor here is what makes this very powerful cuz that means that you cannot just use new product ID with any old string, right? And therefore bypass the validation step. You have to go through this try from method that I

made that itself returns an instance of self. Um which is then where we can validate that boundary. Now, that try from you can tell by the word try already, it will return a result. So, now you can handle that as any other error, right? So, if I'm trying to use a string that I don't know, from unknown origin, and I'm trying to use it as a product

ID, I I have a built-in error handling uh out of the box here. Now, we can use that new type in our previous example. Neat. That that boundary now of that get product function is a lot stricter than it was before and therefore also a lot more foolproof. Um it's going to be a lot harder now to accidentally pass in a wrong arbitrary string value and have

the program treat it like a product ID. And then by re-using that result type from an earlier example in the type parsing, we're also adding that explicit error handling into the mix here. Cuz we know, just by looking at the code now, how that parsing can fail. We know there's certain errors that we can match. You know, in my example, there was only one, but you can

easily imagine, you know, how you how you can validate the type. Um and then handle it. Now, in this example, we still have that other troublesome bit, right? For the S card response. So, we can solve that in a very similar parse and validate type of way with using a validation library. Um ideally one that has proper TypeScript support. We still use Zod nowadays, which is a

a great library for this type of thing, mainly because it also has a way to do the parsing without throwing an error. Um so, you might have seen something like this before. I believe Velobot and other services nowadays, they all use a very similar setup in in how these are defined, right? Now, of course, this is a little bit more work than write writing a TypeScript interface,

but the runtime and build time guarantees are are more than worth it. Um a little note on on future-proofing some of this stuff. Ooh, buzzword. I hate it already. But, note how there's that dot strip at the end here, and that basically just strips out and ignores any properties that you don't recognize. Now, in important bit for that is that if your API does change in a

backwards compatible way in the future, you want to make sure that your from that logic is future compatible for that change as well, right? So, you don't want to just blanketly fail because the API added one more additional optional property. You want to make sure you can just handle that gracefully, right? The front end doesn't need it, you can silently ignore That's also why I am only

explicitly matching on that selected product ID cuz that's the only thing I know I need out of this type at this point. Um Zod natively has that safe parse that we can use, which already uses a very similar sort of result output type. And by combining that with our class example from just now, we can end up with a very very similar setup. So, we kind of

get the both best of both of those worlds, right? We have the very strict validation at the boundary where our unknown input is parsed into a known safe value in runtime using actual validation. And we now also have a very strict type that requires this exact object shape that is created and parsed in this class to be used in the build time. So, therefore, again, if the

type checker passes, you have a lot more guarantees that it actually works as intended. We can now use this in the very similar way in our original snippet. Um so, in this example, you know, we're still silently ignoring errors, never a good time, never great. In real life, you'd probably return that error early, right? So, whenever you see that it's an error type, bubble it up so

you can handle it gracefully on the call site. Thank you so much. So, for example, you're building an API, you want to show it as like a 400 something, yeah, maybe like a 422 response type, right? And that's that's then where you handle the proper error Uh you might end up doing something like this, but as you can imagine, this level of branching gets quite hard to

keep up with, especially in those bigger workflows. Uh better result has a great little utility for it. It's a little strange to read at first maybe with the generative or sort of generator async functions, uh but take a take a peek at the better result docs. They they have you covered for this kind of stuff. So, that's boundary validation. We're taking that untrusted data in as a

unknown. We're parsing it once at the boundary. And then from that point onwards, we have that security and that guarantee that the type doesn't change moving forwards. Last but not least, in Repa Tempo, local mutations. And this is where by default Rust and TypeScript feel like they're sort of furthest apart to me. Cuz in JavaScript, most everything by default is mutable. Right? In this example, loads of

things loads of things in the page are mutable. But you can't really tell just from the type signatures what is supposed to be mutated and when and what isn't. So, const and let was a step in the right direction, but confusingly, you know, a lot of values in a const are still mutable if it's an array or an object. And that ends up in a sort of

situation of, okay, does add item here, the function, is that intended to mutate the cart or is it going to return a new one? Um does apply coupon recalculate the totals? Does another part of the app still reference one of these objects? And then you have weird mismatches. If every single function could potentially mutate these objects, there's really no source of truth anymore, right? And the type

signatures really don't tell you much. Now, in Rust, the difference between reading and mutating is very visible in the function definition itself. So, in this example, in the top one, we have that cart, which is a borrowed readable version of that cart. And then there's a special at mut or ampersand mut keyword that says, okay, I want to have a borrow to the cart, but I'm going

to mutate it. I'm going to have the ability to mutate it. Small difference, but it's very important as it tells the caller and the compiler and the reviewer that this function is now allowed to and therefore probably will change that value directly. Now, TypeScript doesn't have the concept of a mut keyword or something similar. There is a way to do it by effectively just having two versions

of the same object, right? Cuz that's TypeScript's engine, um if one object shape matches the other, it will still allow it to be used, but not the other way around. So, in this particular case, you know, you can use a cart uh sorry, you can use a mutt cart in the place of a cart, but not a cart in place of a mutt cart. Um so, for

a reviewer, it makes it a lot easier to reason about. We can take that same idea a step further if we wanted to. If we're working with a state machine like before, we can use the TypeScript extract helper to match it against a specific version specific version of the state of your discriminated union. This is the point where it's starting to get a little bit complicated cuz

we're, you know, layering all these different approaches. Um I'll I'll put the slides somewhere on the internet. I'll tell you where to find me. So, that is the last of the four there. That's the local mutation piece. Again, we're not trying to bring Rust's borrow checker and compiler into TypeScript, but we can borrow a lot of this explicit contracts between types and runtime guarantees and mutability and

state. Um now, before we move on, quick honorable mention, which is boring tooling. The JS ecosystem has been really good at allowing you to sort of compose all your stack together. You might all have different formatters, different linters, and tools like that. generally, it's a bit of a strength of the JS ecosystem. It allows us to move really, really quickly, but it also means that every project

is different. Right? If you've ever onboarded into a new codebase or a new project or a new open source repo or a new company or whatever, you always have to figure out like, okay, is it is it running lint or test colon or is it what what are all these commands and what are the tools? Um in the Rust land, it's basically just a one thing, that's

it. So, every project, you know, uses Cargo. Um that is a sort of NPM mixed with uh formatter and a linter and some other tooling. And because every Rust project starts with the same base, it also means that all of the common commands that you need are the same for every project. Uh but though recently in JavaScript, it was a bit of an unsolved problem. You know,

Rome tried it a couple years ago, but it unfortunately came and went. Uh but earlier friends earlier this year our friends over at Void Zero announced V plus. So, it's basically a unified tool chain like the one in Rust but for the web. Highly recommend checking it out if you haven't already. Ironically enough V plus is made out of tools built in Rust themselves so it's also

a bit of a fun full circle moment here for me. So, that's the full list. Make those impossible states impossible, make the expected failures visible, validate those interested boundaries and keep that mutation local if you can. Um now a very quick note on AI cuz of there's the big reason that this matters now more than ever before is because of that amount of code produced, right? With

weekly typed TypeScript it is way too easy to sneak in a bunch of of unintended assumptions and states that you that are very hard to catch from a human reviewer and even hard to catch from an agentic reviewer. Uh so, by making TypeScript as strict as possible using some of these techniques, you can avoid a lot of those bugs in the review cycle and also help the

agents catch more problems earlier on cuz it knows what to look for. Uh of course there's tons of trade-offs. One is that it takes a long time to talk about all of this stuff. Um the TLDR there is really of course it takes a lot more work, right? It's like we have to prepare a lot more boiler platey things. We have to prepare all these types we

have to think about and then set it up. But at the end of the day, ambiguity has a cost too and I think that cost arrives a lot later which makes it easier to ignore the beginning. So, I do highly recommend spend a little bit more time up front making the type strict and then you'll you'll won't regret it after the fact. So, takeaways here. Make that

wrong thing hard. Just make it hard to do the wrong thing and your life will be a lot better. Make those expected failures visible. Super important. Stuff will go wrong. It's part of the business. So, better just be open about it than just hide it away in exception trees. Uh validate at that boundary. Parse zone validate. Validate it once once the unknown data comes in and from

that point onwards you can use it as a trusted shape. >> [snorts] >> Keep those mutations local whenever you Uh dare to experiment. You know, I I all of this stuff came out of me just looking at Rust and some of our code base. I was like, "Why aren't we not doing this in TypeScript? Let's see what happens if I do, right?" Um do that. Look over

the fence, other languages, other libraries, other other frameworks. Dare to experiment. Especially in a world where AI will generate the code based on existing patterns. It's up to us to create the new patterns. And last but not least, don't don't build your own CMS. Please don't. You know, I'm I'm doing this so you don't have to. Uh don't don't get burned on it. And with that I

want to say, "Thank you very much." I know it has been a lot. Um that very much is a demo from a different slide that I copy-pasted in. don't talk about that. But, you can find me online at ikonzaltan X or at ik.myc on Blue Sky. Our website is directus.com nowadays. Finally. And I want to say with that, enjoy the rest of the conference. Thank

From event

DEVWorld 2026

07 May 2026 – 08 May 2026

All event videos
Back to Watch