Formalizing JavaScript’s Dynamic Behavior

JavaScript’s dynamic nature is a double-edged sword. It lets developers express complex behavior concisely, but it forces them to hold a lot of contextual information in their heads. Errors creep in easily—typos, invalid values, or logic that contradicts the actual runtime state. The following example of an Express-style server illustrates the problem:

app.get("/api/users/:userID", function(req, res) {
  if (req.method === "POST") {
    res.status(20).send({
      message: "Got you, user " + req.params.userId
    });
  }
})

This server defines a route and executes a callback when a request hits that URL. The callback receives a request object (with the HTTP method and parsed URL parameters) and a response object (to set status codes and send data back). The implementation looks straightforward but contains three distinct bugs:

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 */
    });
  }
})
  1. Although the route is registered with app.get, the code checks for "POST" in req.method. That branch is unreachable, meaning the server would never send a response and clients would eventually time out.
  2. Setting a status code of 20 is invalid—clients won’t understand the response.
  3. The response accesses userId but the parameter is userID. Users would see “Welcome, user undefined!”

These kinds of bugs are common in dynamically typed code. TypeScript exists to formalize the type system that JavaScript already has, catching such mistakes before they make it into production. As Anders Hejlsberg put it, “it’s not that JavaScript has no type system. There is just no way of formalizing it.” TypeScript’s job is to understand your code—sometimes better than you do—and to let you provide extra type information where it can’t infer enough on its own.

Starting with Basic Types

A good starting point is to give the get method explicit types. path is a string, and the callback is a compound type we define ourselves:

const app = {
  get, /* post, put, delete, ... to come! */
};

function get(path: string, callback: CallbackFn) {
  // to be implemented --> not important right now
}

CallbackFn takes two arguments: a ServerRequest and a ServerReply, returning void:

type CallbackFn = (req: ServerRequest, reply: ServerReply) => void;

The ServerRequest interface keeps things simple: a method field for the HTTP verb, and a params record mapping string keys to string values:

type ServerRequest = {
  method: string;
  params: Record<string, string>;
};

ServerReply defines a send function for the response payload and a status function to set the HTTP status code:

type ServerReply = {
  send: (obj?: any) => void;
  status: (statusCode: number) => ServerReply;
};

These basic types already eliminate whole categories 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 they are still too permissive. Any number is acceptable as a status code, and any string can be used as an HTTP method. Refining these to smaller sets is the next step.

Narrowing the Allowed Values

Primitive types represent the set of all possible values of that kind: string covers every string, number covers all double-precision floats, boolean covers both true and false. TypeScript lets you create subsets with unions of literal types—the smallest possible units of a type:

type Methods= "GET" | "POST" | "PUT" | "DELETE";

type ServerRequest = {
  method: Methods;
  params: Record<string, string>;
};

The Method type now explicitly allows only the four common HTTP verbs. The immediate benefit is that TypeScript can guide you through exhaustive checks. As you add case statements for each method, TypeScript knows when you’ve handled all possibilities and will tell you when a default branch can never be reached.

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;
  }
});

Status codes can be constrained the same way, by defining a StatusCode type as a union of valid numbers:

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;
};

Once that is in place, code that tries to set an invalid status code fails at compile time:

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'
 }
})

Using Generics for Context-Aware Types

The current typing is safer, but it still misses an important piece of context. When you call app.get, you know—and TypeScript should know—that the only possible method is "GET". The callback still requires exhaustive checks over all four methods because ServerRequest uses the full Method union.

Generics solve this by letting you parameterize the interface. ServerRequest becomes a generic type with a parameter Met constrained to a subset of Methods:

type ServerRequest<Met extends Methods> = {
  method: Met;
  params: Record<string, string>;
};

This allows defining distinct request types without duplication:

type OnlyGET = ServerRequest<"GET">;
type OnlyPOST = ServerRequest<"POST">;
type POSTorPUT = ServerRquest<"POST" | "PUT">;

The change ripples through the other definitions. CallbackFn and app.get must propagate the generic parameter:

type CallbackFn<Met extends Methods> = (
  req: ServerRequest<Met>,
  reply: ServerReply
) => void;

function get(path: string, callback: CallbackFn<"GET">) {
  // to be implemented
}

Now when a route is registered with app.get, the callback’s req.method has exactly one possible value:

app.get("/api/users/:userID", function (req, res) {
  req.method; // can only be get
});

This precision has a practical payoff beyond the route definitions themselves. You can write general-purpose callback functions outside of app.get that remain fully type-safe, because the narrower type accurately reflects what the runtime will actually provide:

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

Filling In the params Gap

The ServerRequest type still treats params as an open record of strings. Adding a second generic parameter tightens that up:

type ServerRequest<Met extends Methods, Par extends string = string> = {
  method: Met;
  params: Record<Par, string>;
};

With Par defaulting to string but accepting a union of literal keys, you can now declare exactly which parameters a request should carry:

// request.method = "GET"
// request.params = {
//   userID: string
// }
type WithUserID = ServerRequest<"GET", "userID">

Threading the new generic through get and CallbackFn gives you typed access inside the handler:

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;
app.get<"userID">("/api/users/:userID", function (req, res) {
  req.params.userID; // Works!!
  req.params.anythingElse; // 💥 doesn’t work!!
});

That still leaves one hole: the path argument accepts any string, even one that doesn't include the declared parameters. TypeScript 4.1's template literal types close it. A helper type can enforce that the route string contains the Express-style /:param segments:

type IncludesRouteParams<Par extends string> =
  | `${string}/:${Par}`
  | `${string}/:${Par}/${string}`;

IncludesRouteParams builds a union of two template literals. The first matches a route where the parameter appears at the very end, after a /:. The second catches the parameter in the middle by expecting another / and any remaining string afterward. The result behaves predictably across various route shapes:

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" // 💥

Plugging that constraint into get means a route like "/api/users" will no longer compile if you've declared "userID" as a required param:

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!
  }
);

Binding Generics In Reverse

This approach has two shortcomings. First, you must repeat the parameter names in the generic argument even though they're already written in the path string. Second, a union like "userID" | "orderId" only requires one of those to appear in the path—set semantics don't demand all members.

A better direction is to derive the params from the path itself. That relies on generic binding. Consider a plain identity function:

function identity<T>(inp: T) : T {
  return inp
}

Explicitly binding T to string restricts both input and output:

const z = identity<string>("yes"); // z is of type string

Leave off the binding and TypeScript infers T as the string literal type of whatever you pass:

const y = identity("yes") // y is of type "yes"

You can exploit that inference in get by binding the Path type parameter to the actual first argument, then letting a new ParseRouteParams type do the extraction:

function get<Path extends string = string>(
  path: Path,
  callback: CallbackFn<"GET", ParseRouteParams<Path>>
) {
  // to be implemented
}

Parsing Routes With Conditional Types

Conditional types work like a ternary at the type level. A first cut of ParseRouteParams can strip a single trailing parameter:

type ParseRouteParams<Rte> =
  Rte extends `${string}/:${infer P}`
  ? P
  : never;

Testing that on a few routes shows the expected extraction:

type Params = ParseRouteParams<"/api/user/:userID"> // Params is "userID"

type NoParams = ParseRouteParams<"/api/user"> // NoParams is never --> no params!

The full version needs a second branch for params that sit mid-route:

type ParseRouteParams<Rte> = Rte extends `${string}/:${infer P}/${infer Rest}`
  ? P | ParseRouteParams<`/${Rest}`>
  : Rte extends `${string}/:${infer P}`
  ? P
  : never;

The logic now runs recursively: when a parameter appears before other segments, it's pulled into a union and the remaining suffix is re-parsed; when the param is terminal, it's captured directly; otherwise the type resolves to never.

With that in place, calling app.get("/api/users/:userID/orders/:orderID", ...) gives you a typed params object containing userID and orderID—without repeating the names in a generic argument:

// Params is "userID"
type Params = ParseRouteParams<"/api/user/:userID">

// MoreParams is "userID" | "orderID"
type MoreParams = ParseRouteParams<"/api/user/:userID/orders/:orderId">
app.get("/api/users/:userID/orders/:orderID", function (req, res) {
  req.params.userID; // YES!!
  req.params.orderID; // Also YES!!!
});

Compile-Time Checks For Runtime Behavior

The combined types now reject several classes of mistakes before the server ever starts:

  • Invalid numeric status codes are blocked at the call to res.status().
  • The req.method field is constrained to one of four literals, and app.get guarantees it is "GET" inside its callback.
  • Route parameter names are extracted from the path and enforced on params, catching typos in the handler.
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'?
    });
  }
})

Express-style routing is a good stress test for a type system because the shape of the callback depends entirely on runtime strings. A small set of precise types—template literals, conditional types, and generic inference—moves those dynamic contracts into the editor. Static types do the checking at compile time, not when the request fails. You can experiment with the full example in the TypeScript playground.