• Stars
    star
    206
  • Rank 190,504 (Top 4 %)
  • Language
    TypeScript
  • License
    ISC License
  • Created over 3 years ago
  • Updated about 3 years ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

static-path

Many web applications have routes defined with Rails/Express-style syntax. Here's an example with React Router, but the same idea applies to most routers.

<Route exact path="/lessons/:lessonId" component={Lesson} />

Then other parts of the application link to that route:

<a href={`/lessons/${lessonId}`} />

The links and the routes don't know about each other. Each is just a string.

What happens when we want to change the structure of the paths? For example, maybe we want to include the lesson's course in the path: '/courses/:courseId/lessons/:lessonId'. If we change that route, we also have to change every link that points to a lesson. Any links that we miss become 404s. And it's very easy to miss them!

Static-path uses the type system to prevent these 404s at compile time. In the scenario above, every link to the lesson page causes a type error because it's missing the new :courseId param. That forces us to update each link, ensuring that they match the new route pattern.

Install

# with npm
npm install static-path

# with Yarn
yarn add static-path

Usage

Think about your paths, router, and links as three separate pieces:

  • You define which paths exist.
  • You pass those paths' patterns to your router of choice.
  • You link to those paths.

Defining paths

Create a path by calling the path function with a Rails/Express-style route pattern.

The path objects that you get back are functions. You can generate a concrete path by calling them with some params. You'll get a type error if you pass unexpected params, or you omit some params, or you give the params the wrong names.

import { path } from 'static-path'

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

// This returns '/courses/typescript'.
course({courseId: 'typescript'})

// All of these are type errors.
course({courseIdMisnamed: 'typescript'})
course({})
course({courseId: 'typescript', someOtherParam: 'some value'})

When you have a path object, you can use it to define subpaths. A subpath gets all of its parent's params, plus whatever additional params you define in its route string. This is useful for organizing nested paths without duplicating sections of their patterns.

const course = path('/courses/:courseId')
const lesson = course.path('/lessons/:lessonId')

// This returns '/courses/typescript/lessons/type-predicates'.
lesson({courseId: 'typescript', lessonId: 'type-predicates'})

// Both of these are type errors.
lesson({courseId: 'typescript'})
lesson({lessonId: 'type-predicates'})

Static-path only supports params like ':courseId'. Its patterns don't support repetition, conditional pattern components, or anything other than literal strings and :params. If you need those advanced features, you can always handle them outside of static-path, in whatever way you would've handled them before you read this README! Or you can use the pattern shown below in the FAQ.

Routing

All static-paths have a pattern property with a normalized version of the pattern that you provided. You can pass that pattern to your router of choice.

For example, Execute Program uses React Router like this (with irrelevant details removed):

// src/client/router.tsx

import React from 'react'
import { BrowserRouter, Route, Switch } from 'react-router-dom'

import * as paths from '../common/paths'
import { Course } from './pages/course'
import { Lesson } from './pages/lesson'

export function Router() {
  return <BrowserRouter>
    <Switch>
      <Route exact path={paths.course.pattern} component={Course} />
      <Route exact path={paths.lesson.pattern} component={Lesson} />
    </Switch>
  </BrowserRouter>
}

The paths module is the single source of truth about the patterns, so there's no duplication here. (Although it's true that the router module becomes very boring with static-path. But boring code won't hurt you. Clever code is what will hurt you!)

Linking

To link to a path, call it as a function while providing the appropriate params:

import React from 'react'
import * as paths from '../common/paths'

export function LinkToALesson() {
  return <a href={paths.lesson({courseId: 'typescript', lessonId: 'type-predicates'})}>
    Try this lesson!
  </a>
}

Integrating with Express and other server-side routers

Recent versions of @types/express can statically determine route parameter names from the route pattern. That feature integrates cleanly with static-path.

The code below type checks. The req.params.courseId property access is allowed because the static-path contains a :courseId parameter, and the type information flows from there into the Express app.get call. If we try to access req.params.someOtherParam, it will type error.

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

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

Other Express-style routers should work in the same way. If not, those projects need to add param name extraction types similar to those in Express.

Recommended usage

  1. Create a paths.ts module to hold your paths. In a large application, you may want to break it up into multiple sub-modules for organization. The important part is that it's a self-contained module or family of modules.

  2. Your paths.ts should be importable by both your server and your client. That way, code on both sides of the app can safely link to paths. How you achieve this will depend on your web framework. Put paths.ts wherever you put other code that's shared by client and server.

  3. Always pass a literal string directly to the path function, like path('/courses/:courseId'). Never pass variables or other expressions to path. It's important for path(...) to see the literal string type of your pattern. Passing a variable with the type string, or a union of multiple strings, may produce a confusing type error. It may also let incorrect links slip through.

  4. Generate all of your links via static-paths. Static-path can only help you if you use it to generate your links. Any link created in the old way, like <a href={/courses/:courseId}>, is invisible to static-path.

FAQ

Is this a router?

No. Static-path doesn't do any routing. It only manages the relationship between patterns like '/courses/:courseId', params like {courseId: 'typescript'}, and concrete path strings like '/courses/typescript'. You can use it with any router that supports the usual pattern syntax with :params.

What about my bundle size?

Static-path is under 1 KiB minified and gzipped.

Can I use this in JavaScript?

You can technically use static-path in JavaScript, but we don't think there's a reason to. This library's purpose is to statically type code that's particularly prone to dynamic mistakes. In JavaScript, this library can still be used to generate concrete path strings from path objects, but the primary benefit of static type errors is lost. We recommend porting to TypeScript first.

Is it production-ready?

Execute Program has used this library in production since December of 2020.

How does it work?

It all hinges on a type that combines template literal types with a conditional type to extract the param names from the route pattern string. Prior to TypeScript adding template literal type support, this wasn't possible without code generation.

Doesn't this make the code verbose?

Not really. In fact, if you have your path parameters in appropriately-named local variables then it's shorter than the traditional method. Here's a comparison of static-path vs. a hard-coded path in that scenario:

// static-path
<a href={paths.lesson({courseId, lessonId})} />
// hard-coded path
<a href={`/courses/${courseId}/lessons/${lessonId}`} />

I need fancy route features like conditionals and repetition

The vast majority of routes don't need these features, but some do. In these cases, you can always write a function. Static-paths are functions that take params, return path strings, and have a pattern property. You can define such a function yourself inside of your paths.ts module, then use it alongside other static-paths.

Here's an example. We want a path that takes a courseId and a lessonId. But we also want to allow calls without any courseId, in which case we'll use a default course ID instead.

// The function generates concrete path strings, just like static-paths do.
export function lessonWithDefault(params: {
  lessonId: string,
  courseId?: string
}) {
  /* If we don't provide a course ID, assume that we're linking to the
   * TypeScript course. */
  const courseId = params.courseId ?? 'typescript'

  return `/courses/${courseId}/lessons/${params.lessonId}`
}

/* It's up to us to make the `pattern` property match what the function
 * generates. */
lessonWithDefault.pattern = '/courses/:courseId/lessons/:lessonId'

Note that the lessonWithDefault path function doesn't use static-path at all. As a result, static-path can't check for whether the pattern actually matches the params, or whether those match the template literal string that generates the paths. This approach lets you build arbitrarily complex paths, but you have to make sure that your params and patterns line up.

If you write functions like these, you're still better off than you would be with the traditional method of smashing strings together in an ad-hoc way. This function still serves as a single point of truth describing the paths, rather than writing code to manually build the paths at every point where you link to them.

What if I hard-code some links instead of calling the path functions?

Don't do it! The benefits of static-path come from using it as the single source of truth about the paths. Each time you write <a href={`/courses/{courseId}`}>, you create a link that's invisible to static-path. If you later change the structure of that route, you won't get a type error telling you that that <a> tag is out of date. Always use the path objects when linking.

How do I force a param to be a number or other type?

Call .toString() on it. Static-path only supports string values as params.

Encoding type information in the route strings would dramatically increase this library's complexity, if it's possible at all. It would probably lead to worse error messages as well. Static-path is already safer than the traditional approach. Let's try to get to 90% before reaching for 100%!

More Repositories

1

dotfiles

~grb. Things in here are often interdependent. A lot of stuff relies on scripts in bin/.
Vim Script
1,901
star
2

selecta

A fuzzy text selector for files and anything else you need to select. Use it from vim, from the command line, or anywhere you can run a shell command.
Ruby
1,345
star
3

base

The universal Base class you've always wanted.
Ruby
436
star
4

raptor

An experimental web framework.
Ruby
367
star
5

readygo

A Ruby benchmarking tool accurate to sub-nanosecond time scales
Ruby
222
star
6

destroy-all-software-extras

Extra material for Destroy All Software Screencasts
Ruby
202
star
7

serveit

ServeIt, a synchronous server and rebuilder of static content like blogs, books, READMEs, etc.
Ruby
156
star
8

expecter

Expecter Gadget: better expectations (assertions) for Python.
Python
103
star
9

dingus

A record-then-assert test double library.
Python
98
star
10

do_not_want

(UNMAINTAINED) Do Not Want: Stops ActiveRecord from doing things you probably didn't want
Ruby
83
star
11

sucks-rocks

Does it suck? Or does it rock?
Ruby
58
star
12

pycomplexity

Scripts to show cyclomatic complexity of Python code in Vim and Emacs.
Python
46
star
13

cls

Cls: terse syntax for your classes
Ruby
40
star
14

python-mock-comparison

A comparison of Python's mocking and other test double libraries
Python
27
star
15

rubies

Ruby
24
star
16

blocks

Python
16
star
17

mote

A very experimental spec runner for Python. Beware: it is incomplete and may change drastically.
Python
12
star
18

LuaSpec

An executable Lua specification so I remember what I've learned.
Lua
12
star
19

explicit_import

Explicit imports for Ruby on a per-class/module basis
Ruby
9
star
20

prest

A REST client library for Python extracted sloppily from BitBacker
Python
5
star
21

the-march-of-progress

Find all the progress indicators from running applications. This does not solve an actual problem.
Ruby
3
star