Dynamic Static Typing In TypeScript
JavaScript is an inherently dynamic programming language. We as developers can express a lot with little effort, and the language and its runtime figure out what we intended to do. This is what makes JavaScript so popular for beginners, and which makes experienced developers productive! There is a caveat, though: We need to be alert! Mistakes, typos, correct program behavior: A lot of that happens in our heads!
Take a look at the following example.
app.get("/api/users/:userID", function(req, res) {
if (req.method === "POST") {
res.status(20).send({
message: "Got you, user " + req.params.userId
});
}
})
We have an https://expressjs.com/-style server that allows us to define a route (or path), and executes a callback if the URL is requested.
The callback takes two arguments:
- The
request
object.
Here we get information on the HTTP method used (e.g GET, POST, PUT, DELETE), and additional parameters that come in. In this exampleuserID
should be mapped to a parameteruserID
that, well, contains the user’s ID! - The
response
orreply
object.
Here we want to prepare a proper response from the server to the client. We want to send correct status codes (methodstatus
) and send JSON output over the wire.
What we see in this example is heavily simplified, but gives a good idea what we are up to. The example above is also riddled with errors! Have a look:
app.get("/api/users/:userID", function(req, res) {
if (req.method === "POST") { /* Error 1 */
res.status(20).send({ /* Error 2 */
message: "Welcome, user " + req.params.userId /* Error 3 */
});
}
})
Oh wow! Three lines of implementation code, and three errors? What has happened?
- The first error is nuanced. While we tell our app that we want to listen to GET requests (hence
app.get
), we only do something if the request method is POST. At this particular point in our application,req.method
can’t be POST. So we would never send any response, which might lead to unexpected timeouts. - Great that we explicitly send a status code!
20
isn’t a valid status code, though. Clients might not understand what’s happening here. - This is the response we want to send back. We access the parsed arguments but have a mean typo. It’s
userID
notuserId
. All our users would be greeted with "Welcome, user undefined!". Something you definitely have seen in the wild!
And things like that happen! Especially in JavaScript. We gain expressiveness -- not once did we have to bother about types -- but have to pay close attention to what we’re doing.
This is also where JavaScript gets a lot of backlash from programmers who aren’t used to dynamic programming languages. They usually have compilers pointing them to possible problems and catching errors upfront. They might come off as snooty when they frown upon the amount of extra work you have to do in your head to make sure everything works right. They might even tell you that JavaScript has no types. Which is not true.
Anders Hejlsberg, the lead architect of TypeScript, said in his MS Build 2017 keynote that “it’s not that JavaScript has no type system. There is just no way of formalizing it”.
And this is TypeScript’s main purpose. TypeScript wants to understand your JavaScript code better than you do. And where TypeScript can’t figure out what you mean, you can assist by providing extra type information.
Basic Typing
And this is what we’re going to do right now. Let’s take the get
method from our Express-style server and add enough type information so we can exclude as many categories of errors as possible.
We start with some basic type information. We have an app
object that points to a get
function. The get
function takes path
, which is a string, and a callback.
const app = {
get, /* post, put, delete, ... to come! */
};
function get(path: string, callback: CallbackFn) {
// to be implemented --> not important right now
}
While string
is a basic, so-called primitive type, CallbackFn
is a compound type that we have to explicitly define.
CallbackFn
is a function type that takes two arguments:
req
, which is of typeServerRequest
reply
which is of typeServerReply
CallbackFn
returns void
.
type CallbackFn = (req: ServerRequest, reply: ServerReply) => void;
ServerRequest
is a pretty complex object in most frameworks. We do a simplified version for demonstration purposes. We pass in a method
string, for "GET"
, "POST"
, "PUT"
, "DELETE"
, etc. It also has a params
record. Records are objects that associate a set of keys with a set of properties. For now, we want to allow for every string
key to be mapped to a string
property. We refactor this one later.
type ServerRequest = {
method: string;
params: Record<string, string>;
};
For ServerReply
, we lay out some functions, knowing that a real ServerReply
object has much more. A send
function takes an optional argument with the data we want to send. And we have the possibility to set a status code with the status
function.
type ServerReply = {
send: (obj?: any) => void;
status: (statusCode: number) => ServerReply;
};
That’s already something, and we can rule out a couple of errors:
app.get("/api/users/:userID", function(req, res) {
if(req.method === 2) {
// ^^^^^^^^^^^^^^^^^ 💥 Error, type number is not assignable to string
res.status("200").send()
// ^^^^^ 💥 Error, type string is not assignable to number
}
})
But we still can send wrong status codes (any number is possible) and have no clue about the possible HTTP methods (any string is possible). Let’s refine our types.
Smaller Sets
You can see primitive types as a set of all possible values of that certain category. For example, string
includes all possible strings that can be expressed in JavaScript, number
includes all possible numbers with double float precision. boolean
includes all possible boolean values, which are true
and false
.
TypeScript allows you to refine those sets to smaller subsets. For example, we can create a type Method
that includes all possible strings we can receive for HTTP methods:
type Methods= "GET" | "POST" | "PUT" | "DELETE";
type ServerRequest = {
method: Methods;
params: Record<string, string>;
};
Method
is a smaller set of the bigger string
set. Method
is a union type of literal types. A literal type is the smallest unit of a given set. A literal string. A literal number. There is no ambiguity. It’s just "GET"
. You put them in a union with other literal types, creating a subset of whatever bigger types you have. You can also do a subset with literal types of both string
and number
, or different compound object types. There are lots of possibilities to combine and put literal types into unions.
This has an immediate effect on our server callback. Suddenly, we can differentiate between those four methods (or more if necessary), and can exhaust all possibilites in code. TypeScript will guide us:
app.get("/api/users/:userID", function (req, res) {
// at this point, TypeScript knows that req.method
// can take one of four possible values
switch (req.method) {
case "GET":
break;
case "POST":
break;
case "DELETE":
break;
case "PUT":
break;
default:
// here, req.method is never
req.method;
}
});
With every case
statement you make, TypeScript can give you information on the available options. Try it out for yourself. If you exhausted all options, TypeScript will tell you in your default
branch that this can never
happen. This is literally the type never
, which means that you possibly have reached an error state that you need to handle.
That’s one category of errors less. We know now exactly which possible HTTP methods are available.
We can do the same for HTTP status codes, by defining a subset of valid numbers that statusCode
can take:
type StatusCode =
100 | 101 | 102 | 200 | 201 | 202 | 203 | 204 | 205 |
206 | 207 | 208 | 226 | 300 | 301 | 302 | 303 | 304 |
305 | 306 | 307 | 308 | 400 | 401 | 402 | 403 | 404 |
405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 |
414 | 415 | 416 | 417 | 418 | 420 | 422 | 423 | 424 |
425 | 426 | 428 | 429 | 431 | 444 | 449 | 450 | 451 |
499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 |
508 | 509 | 510 | 511 | 598 | 599;
type ServerReply = {
send: (obj?: any) => void;
status: (statusCode: StatusCode) => ServerReply;
};
Type StatusCode
is again a union type. And with that, we exclude another category of errors. Suddenly, code like that fails:
app.get("/api/user/:userID", (req, res) => {
if(req.method === "POS") {
// ^^^^^^^^^^^^^^^^^^^ 'Methods' and '"POS"' have no overlap.
res.status(20)
// ^^ '20' is not assignable to parameter of type 'StatusCode'
}
})
And our software becomes a lot safer! But we can do more!
Enter Generics
When we define a route with app.get
, we implicitly know that the only HTTP method possible is "GET"
. But with our type definitions, we still have to check for all possible parts of the union.
The type for CallbackFn
is correct, as we could define callback functions for all possible HTTP methods, but if we explicitly call app.get
, it would be nice to save some extra steps which are only necessary to comply with typings.
TypeScript generics can help! Generics are one of the major features in TypeScript that allow you to get the most dynamic behaviour out of static types. In TypeScript in 50 Lessons, we spend the last three chapters digging into all the intricacies of generics and their unique functionality.
What you need to know right now is that we want to define ServerRequest
in a way that we can specify a part of Methods
instead of the entire set. For that, we use the generic syntax where we can define parameters as we would do with functions:
type ServerRequest<Met extends Methods> = {
method: Met;
params: Record<string, string>;
};
This is what happens:
ServerRequest
becomes a generic type, as indicated by the angle brackets- We define a generic parameter called
Met
, which is a subset of typeMethods
- We use this generic parameter as a generic variable to define the method.
I also encourage you to check out my article on naming generic parameters.
With that change, we can specify different ServerRequest
s without duplicating things:
type OnlyGET = ServerRequest;
type OnlyPOST = ServerRequest;
type POSTorPUT = ServerRquest;
Since we changed the interface of ServerRequest
, we have to make changes to all our other types that use ServerRequest
, like CallbackFn
and the get
function:
type CallbackFn<Met extends Methods> = (
req: ServerRequest<Met>,
reply: ServerReply
) => void;
function get(path: string, callback: CallbackFn<"GET">) {
// to be implemented
}
With the get
function, we pass an actual argument to our generic type. We know that this won’t be just a subset of Methods
, we know exactly which subset we are dealing with.
Now, when we use app.get
, we only have on possible value for req.method
:
app.get("/api/users/:userID", function (req, res) {
req.method; // can only be get
});
This ensures that we don’t assume that HTTP methods like "POST"
or similar are available when we create an app.get
callback. We know exactly what we are dealing with at this point, so let’s reflect that in our types.
We already did a lot to make sure that request.method
is reasonably typed and represents the actual state of affairs. One nice benefit we get with subsetting the Methods
union type is that we can create a general purpose callback function outside of app.get
that is type-safe:
const handler: CallbackFn<"PUT" | "POST"> = function(res, req) {
res.method // can be "POST" or "PUT"
};
const handlerForAllMethods: CallbackFn<Methods> = function(res, req) {
res.method // can be all methods
};
app.get("/api", handler);
// ^^^^^^^ 💥 Nope, we don’t handle "GET"
app.get("/api", handlerForAllMethods); // 👍 This works
Typing Params
What we haven’t touched yet is typing the params
object. So far, we get a record that allows accessing every string
key. It’s our task now to make that a little bit more specific!
We do that by adding another generic variable. One for methods, one for the possible keys in our Record
:
type ServerRequest<Met extends Methods, Par extends string = string> = {
method: Met;
params: Record<Par, string>;
};
The generic type variable Par
can be a subset of type string
, and the default value is every string. With that, we can tell ServerRequest
which keys we expect:
// request.method = "GET"
// request.params = {
// userID: string
// }
type WithUserID = ServerRequest
Let’s add the new argument to our get
function and the CallbackFn
type, so we can set the requested parameters:
function get<Par extends string = string>(
path: string,
callback: CallbackFn<"GET", Par>
) {
// to be implemented
}
type CallbackFn<Met extends Methods, Par extends string> = (
req: ServerRequest<Met, Par>,
reply: ServerReply
) => void;
If we don’t set Par
explicitly, the type works as we are used to, since Par
defaults to string
. If we set it though, we suddenly have a proper definition for the req.params
object!
app.get<"userID">("/api/users/:userID", function (req, res) {
req.params.userID; // Works!!
req.params.anythingElse; // 💥 doesn’t work!!
});
That’s great! There is one little thing that can be improved, though. We still can pass every string to the path
argument of app.get
. Wouldn’t it be better if we could reflect Par
in there as well?
We can! With the release of version 4.1, TypeScript is able to create template literal types. Syntactically, they work just like string template literals, but on a type level. Where we were able to split the set string
into subsets with string literal types (like we did with Methods), template literal types allow us to include an entire spectrum of strings.
Let’s create a type called IncludesRouteParams
, where we want to make sure that Par
is properly included in the Express-style way of adding a colon in front of the parameter name:
type IncludesRouteParams<Par extends string> =
| `${string}/:${Par}`
| `${string}/:${Par}/${string}`;
The generic type IncludesRouteParams
takes one argument, which is a subset of string
. It creates a union type of two template literals:
- The first template literal starts with any
string
, then includes a/
character followed by a:
character, followed by the parameter name. This makes sure that we catch all cases where the parameter is at the end of the route string. - The second template literal starts with any
string
, followed by the same pattern of/
,:
and the parameter name. Then we have another/
character, followed by any string. This branch of the union type makes sure we catch all cases where the parameter is somewhere within a route.
This is how IncludesRouteParams
with the parameter name userID
behaves with different test cases:
const a: IncludeRouteParams<"userID"> = "/api/user/:userID" // 👍
const a: IncludeRouteParams<"userID"> = "/api/user/:userID/orders" // 👍
const a: IncludeRouteParams<"userID"> = "/api/user/:userId" // 💥
const a: IncludeRouteParams<"userID"> = "/api/user" // 💥
const a: IncludeRouteParams<"userID"> = "/api/user/:userIDAndmore" // 💥
Let’s include our new utility type in the get
function declaration.
function get<Par extends string = string>(
path: IncludesRouteParams<Par>,
callback: CallbackFn<"GET", Par>
) {
// to be implemented
}
app.get<"userID">(
"/api/users/:userID",
function (req, res) {
req.params.userID; // YEAH!
}
);
Great! We get another safety mechanism to ensure that we don’t miss out on adding the parameters to the actual route! How powerful.
Generic bindings
But guess what, I’m still not happy with it. There are a few issues with that approach that become apparent the moment your routes get a little more complex.
- The first issue I have is that we need to explicitly state our parameters in the generic type parameter. We have to bind
Par
to"userID"
, even though we would specify it anyway in the path argument of the function. This is not JavaScript-y! - This approach only handles one route parameter. The moment we add a union, e.g
"userID" | "orderId"
the failsafe check is satisfied with only one of those arguments being available. That’s how sets work. It can be one, or the other.
There must be a better way. And there is. Otherwise, this article would end on a very bitter note.
Let’s inverse the order! Let’s not try to define the route params in a generic type variable, but rather extract the variables from the path
we pass as the first argument of app.get
.
To get to the actual value, we have to see out how generic binding works in TypeScript. Let’s take this identity
function for example:
function identity<T>(inp: T) : T {
return inp
}
It might be the most boring generic function you ever see, but it illustrates one point perfectly. identity
takes one argument, and returns the same input again. The type is the generic type T
, and it also returns the same type.
Now we can bind T
to string
, for example:
const z = identity<string>("yes"); // z is of type string
This explicitly generic binding makes sure that we only pass strings
to identity
, and since we explicitly bind, the return type is also string
. If we forget to bind, something interesting happens:
const y = identity("yes") // y is of type "yes"
In that case, TypeScript infers the type from the argument you pass in, and binds T
to the string literal type "yes"
. This is a great way of converting a function argument to a literal type, which we then use in our other generic types.
Let’s do that by adapting app.get
.
function get<Path extends string = string>(
path: Path,
callback: CallbackFn<"GET", ParseRouteParams<Path>>
) {
// to be implemented
}
We remove the Par
generic type and add Path
. Path
can be a subset of any string
. We set path
to this generic type Path
, which means the moment we pass a parameter to get
, we catch its string literal type. We pass Path
to a new generic type ParseRouteParams
which we haven’t created yet.
Let’s work on ParseRouteParams
. Here, we switch the order of events around again. Instead of passing the requested route params to the generic to make sure the path is alright, we pass the route path and extract the possible route params. For that, we need to create a conditional type.
Conditional Types And Recursive Template Literal Types
Conditional types are syntactically similar to the ternary operator in JavaScript. You check for a condition, and if the condition is met, you return branch A, otherwise, you return branch B. For example:
type ParseRouteParams<Rte> =
Rte extends `${string}/:${infer P}`
? P
: never;
Here, we check if Rte
is a subset of every path that ends with the parameter at the end Express-style (with a preceding "/:"
). If so, we infer this string. Which means we capture its contents into a new variable. If the condition is met, we return the newly extracted string, otherwise, we return never, as in: "There are no route parameters",
If we try it out, we get something like that:
type Params = ParseRouteParams<"/api/user/:userID"> // Params is "userID"
type NoParams = ParseRouteParams<"/api/user"> // NoParams is never --> no params!
Great, that’s already much better than we did earlier. Now, we want to catch all other possible parameters. For that, we have to add another condition:
type ParseRouteParams<Rte> = Rte extends ${string}/:${infer P}/${infer Rest}
? P | ParseRouteParams</${Rest}
>
: Rte extends ${string}/:${infer P}
? P
: never;
Our conditional type works now as follows:
- In the first condition, we check if there is a route parameter somewhere in between the route. If so, we extract both the route parameter and everything else that comes after that. We return the newly found route parameter
P
in a union where we call the same generic type recursively with theRest
. For example, if we pass the route"/api/users/:userID/orders/:orderID"
toParseRouteParams
, we infer"userID"
intoP
, and"orders/:orderID"
intoRest
. We call the same type withRest
- This is where the second condition comes in. Here we check if there is a type at the end. This is the case for
"orders/:orderID"
. We extract"orderID"
and return this literal type. - If there is no more route parameter left, we return never.
Dan Vanderkam shows a similar, and more elaborate type for ParseRouteParams
, but the one you see above should work as well. If we try out our newly adapted ParseRouteParams
, we get something like this:
// Params is "userID"
type Params = ParseRouteParams
Let’s apply this new type and see what our final usage of app.get
looks like.
app.get("/api/users/:userID/orders/:orderID", function (req, res) {
req.params.userID; // YES!!
req.params.orderID; // Also YES!!!
});
Wow. That just looks like the JavaScript code we had at the beginning!
Static Types For Dynamic Behavior
The types we just created for one function app.get
make sure that we exclude a ton of possible errors:
- We can only pass proper numeric status codes to
res.status()
req.method
is one of four possible strings, and when we useapp.get
, we know it only be"GET"
- We can parse route params and make sure that we don’t have any typos inside our callback
If we look at the example from the beginning of this article, we get the following error messages:
app.get("/api/users/:userID", function(req, res) {
if (req.method === "POST") {
// ^^^^^^^^^^^^^^^^^^^^^
// This condition will always return 'false'
// since the types '"GET"' and '"POST"' have no overlap.
res.status(20).send({
// ^^
// Argument of type '20' is not assignable to
// parameter of type 'StatusCode'
message: "Welcome, user " + req.params.userId
// ^^^^^^
// Property 'userId' does not exist on type
// '{ userID: string; }'. Did you mean 'userID'?
});
}
})
And all that before we actually run our code! Express-style servers are a perfect example of the dynamic nature of JavaScript. Depending on the method you call, the string you pass for the first argument, a lot of behavior changes inside the callback. Take another example and all your types look entirely different.
But with a few well-defined types, we can catch this dynamic behavior while editing our code. At compile time with static types, not at runtime when things go boom!
And this is the power of TypeScript. A static type system that tries to formalize all the dynamic JavaScript behavior we all know so well. If you want to try the example we just created, head over to the TypeScript playground and fiddle around with it.
In this article, we touched upon many concepts. If you’d like to know more, check out TypeScript in 50 Lessons, where you get a gentle introduction to the type system in small, easily digestible lessons. Ebook versions are available immediately, and the print book will make a great reference for your coding library.