garybernhardt/static-path

Pulling out params from a path isn't typechecked?

Closed this issue · 9 comments

Ought I be able to use this on the Express side of things, for example, to typecheck req.params? For example, with

const course = path('/courses/:courseId')

and an Express route

app.get(course.pattern, (req, res) => {
  console.log(req.params['courseiiiiiddddd???'])

is there no way to ensure I don't have a typo in indexing req.prams, i.e., tell the type system that req.params will have the same keys as params in course? I just have to be careful in entering the parameter?

I do see course.parts contains an element, { kind: 'param', paramName: 'courseId' }, but I don't think I can do anything like keyof typeof course['parts'] because parts is available only at runtime—there's no list of params stored in the type itself?

Forgive me if I'm misunderstanding or mis-explaining. Hopefully a full example can help:

import {path} from 'static-path'
const course = path('/courses/:courseId')

import * as express from 'express';
const app = express.default();
app.get(course.pattern, (req, res) => {
  console.log(req.params)
  console.log(req.params['courseIdTypecheckedPlease?'])
  res.send('hi');
});
const port = 3333;
app.listen(port, () => console.log(`Example app listening at http://localhost:${port}`));

With the above in demo.ts and running $ npx ts-node demo.ts in one tab and $ curl -v http://localhost:3333/courses/hello in another, the server printouts reveal that it did get a courseId param but I'd love to have a way to not actually type that key into req.params if I can help it.

Ah! Reading the source and finding Params led me to the following!

import {path, Path, Params} from 'static-path'
const course = path('/courses/:courseId') // same as before

// new:

type extract<T> = T extends Path<infer X> ? X : never // via https://stackoverflow.com/a/50924506/
console.log((req.params as Params<extract<typeof course>>).courseId) // typechecked!

Happy to close this issue, totally open to guidance on how to do this properly. Thank you!

Your timing here was good. I noticed last week that Express's TS types now extract parameters from the route patterns. I was able to extend static-path to make them integrate smoothly. This now works:

const course = path('/courses/:courseId');

app.get(course.pattern, (req, res) => {
  console.log(req.params.courseId;
  res.end();
});

What's going on here is: the Express paths are doing the same kind of literal string type deconstruction that static-path does, finding the ':courseId' in '/courses/:courseId' and treating that as a param property. I changed static-path so that the type of .pattern is now the literal string type of the pattern string (like '/courses/:courseId'), rather than just string. The Express types can then disassemble that literal string type to find the ':courseId' param.

Version 0.0.4 includes this change. I'm pretty sure that I got all of this right. But some of the implementation can't be expressed in TS types, so I had to use one type assertion. Please let me know if you find anything weird. However, this did go into production on https://www.executeprogram.com yesterday without issue.

Wow, thanks Gary!!! I look forward to 0.0.4 dropping 🙏!

It's already up! Please let me know how it goes.

It's already up!

Ahh, I see 0.0.4 on https://www.npmjs.com/package/static-path, thanks! I was misguided by seeing https://github.com/garybernhardt/static-path/commits/main showing the latest commit being 0.0.3 from September 😅.

Oops, I forgot to push to GitHub. Just did that. Thanks!

I thought I was going crazy, because I was happily typechecking my params, all was well, when the TypeScript server suddenly stopped autocompleting req.params.<waiting…>. It turns out, if I have a middleware between the pattern and my handler, it blocks the flow of types? For example, I have this middleware adapted from some Passport.js example—it's not remotely interesting—and then:

// Boring middleware
const ensureAuthenticated: RequestHandler = (req, res, next) => {
  if (req.isAuthenticated && req.isAuthenticated()) {
    next();
  } else {
    bearerAuthentication(req, res, next);
  }
};
// static-path goodies
const bookmarkIdPath = path('/bookmark/:id');
app.get(bookmarkIdPath.pattern, ensureAuthenticated, (req, res) => {
  req.params.SHOULD_NOT_TYPECHECK // typechecks
});

Removing the ensureAuthenticated middleware brings typechecks back.

I'm not sure if I can refactor ensureAuthenticated in such a way that it remains reusable for different patterns and also somehow flows params's types from left to right? Or if there's some Express magic where I can apply the middleware without it being syntactically in the code? In which case I'll just refactor the middleware to run in each handler and step back from using Express' middleware idioms.

Unless you have some ideas?

(Thanks for static-types and for your vigorous promotion of TypeScript-mediated static typing from the database to the frontend! I'm only one of doubtless many happy converts!)

Sorry for the spam. Turns out I just need to allow TypeScript to infer the middleware's generic parameters. If I replace ensureMiddleware with a generic:

import {NextFunction, Request, Response} from 'express';
export function ensureAuthenticated<P>(req: Request<P>, res: Response, next: NextFunction) {
  // boring
};
const bookmarkIdPath = path('/bookmark/:id');
app.get(bookmarkIdPath.pattern, ensureAuthenticated, (req, res) => {
  req.params.SHOULD_NOT_TYPECHECK // 💥 fails to indeed typecheck 🥳
});

Omitting the <P> generic parameter means my req has the default ParamsDictionary type, which is what breaks things. So that P template parameter has to be present for TypeScript to infer it from static-path's pattern and flow it to my req. Thanks for your patience!

Glad that you figured it out. Hopefully this will also help others that hit the same problem.