A codebase types its navigation helper as `function navigate(path: `/users/${string}`): void`. What does that annotation actually guarantee, and where does it stop helping?
answer
- it checks authorship, not execution
- how the string was built matters
- concatenation widens, template strings do not
- external strings arrive as plain string
- a cast is a promise, not a check
basics
~20 sIt only constrains call sites whose argument still carries a literal or template literal type. A value typed plain string is rejected until someone asserts it, and nothing is verified at runtime because the type is erased.
solid answer
~60 sThe guarantee is narrow but real: at any call site where the compiler can see a sufficiently precise type, a path that does not start with `/users/` is a compile error, and editors autocomplete the prefix. It holds for string literals and for template string expressions, because a template string contextually typed by a template literal type is inferred as one — `` navigate(`/users/${id}`) `` checks even when `id` is `string`. It stops helping in three places. Concatenation with `+` produces plain `string`, so `navigate('/users/' + id)` fails and tempts a cast. Any path arriving from outside — a config file, `JSON.parse`, a query parameter, a CMS response — is `string` and needs a real check or an assertion, and the assertion verifies nothing. And because types are erased, a wrong path built dynamically reaches the router exactly as before. The pattern is worth it for paths built in your own code; for external strings, validate at the boundary with a predicate and let the narrowing do the work.
code
typescript · 16 linesdeclare function navigate(path: `/users/${string}`): void;
declare const id: string;
navigate(`/users/${id}`); // ok - contextually typed template string
// @ts-expect-error - `+` produces plain string
navigate("/users/" + id);
// Values from outside are plain string: check, do not cast
function isUserPath(s: string): s is `/users/${string}` {
return s.startsWith("/users/");
}
declare const fromQuery: string;
if (isUserPath(fromQuery)) {
navigate(fromQuery); // narrowed by a check that really ran
}go deeper
Know that the parameter type demands a string starting with /users/ and that a plain string variable will not be accepted. Recognising the error message is the goal at this level.
Explain why a contextually typed template string keeps its precise type while + concatenation and unannotated const bindings widen to string, and show how to annotate an intermediate binding to restore it.
Locate the boundary: identify which paths originate in code and which arrive from query strings, config or APIs, and put a real type predicate at the edge rather than a cast. Say plainly that erasure means no runtime protection.
Own the policy — where structured strings get validated, whether casts on paths are allowed in review, and how far to push type-level route parsing given compile cost, error legibility and the number of people who can maintain it.
## What the annotation buys ```ts declare function navigate(path: `/users/${string}`): void; navigate("/users/42"); // ok navigate("/user/42"); // error — prefix does not match navigate("/orders/42"); // error ``` Every call the checker analyses with a precise enough argument type is verified against the prefix. That is genuinely valuable: it catches the typo class of bug (`/user/` versus `/users/`) at the place the path is written, and editors offer the prefix as you type. Compared with `path: string`, the annotation moves a whole family of mistakes from a 404 in staging to a red squiggle. ## How the argument's type is produced decides everything The check is on the *type*, so the way the string is built matters more than what it contains: ```ts declare const id: string; navigate(`/users/${id}`); // ok navigate("/users/" + id); // error: string is not assignable const p1 = `/users/${id}`; // inferred as string navigate(p1); // error const p2: `/users/${string}` = `/users/${id}`; navigate(p2); // ok ``` Two mechanics are at work. A template string expression that is *contextually typed* by a template literal type is itself inferred as a template literal type, which is why the direct call succeeds. Without that context — assigned to a bare `const`, or built with `+` — the expression widens to `string`, and `string` is not assignable to the narrower pattern. Annotating the intermediate binding restores the context. This is the friction point in real adoption. Developers hit the error on a concatenation, do not understand why the template string version works, and reach for `as` — at which point the annotation has been converted into decoration. ## The boundary problem The deeper limit is that a large share of paths in a real app do not originate in code: ```ts const target = searchParams.get("redirect"); // string | null const route = config.defaultRoute; // string from JSON const link = cmsBlock.href; // string from an API ``` All of these are `string`. The pattern cannot check them, because there is nothing at compile time to check. The two honest responses are a runtime guard or a deliberate assertion: ```ts function isUserPath(s: string): s is `/users/${string}` { return s.startsWith("/users/"); } if (isUserPath(target)) { navigate(target); // narrowed, and the check actually ran } ``` The predicate is a promise the compiler trusts, so its body must genuinely test what the signature claims. Compare with `navigate(target as `/users/${string}`)`, which performs no check at all and silences the one signal you had. For a redirect target the difference is not cosmetic — an unvalidated path is how open-redirect bugs get shipped. ## Erasure sets the ceiling After compilation the annotation is gone: `navigate` receives whatever string the caller produced, and there is no prefix test in the emitted function. So the type constrains authorship, not execution. Any path that crosses a runtime boundary — user input, storage, the network — is outside the guarantee by construction, and only code you write can close that gap. ## Judgment: how far to push it A reasonable position for a production codebase: - **Use the pattern for internally constructed paths.** Cheap to write, catches typos, no compile-time cost worth mentioning. - **Validate once at each boundary** with a type predicate, then let narrowing carry the precise type inward. One check, many typed uses. - **Treat every `as` on a path as a defect to justify.** If a cast is needed, the missing piece is usually a boundary check that was never written. - **Be sceptical of full path parsing.** Extracting parameter names from a route with recursive `infer` is impressive and occasionally right, but it is compile-time work on every keystroke, produces error messages nobody can read, and stops being maintainable when one person leaves the team. Prefer a hand-written parameter type next to the route constant unless the route table is large and genuinely volatile. - **Remember the type is not the router.** If the router accepts arbitrary strings at runtime, the strong signature is a lint on your own code, not a security control. The honest summary to give an interviewer: the annotation is a cheap, high-yield check on strings your code builds, and provably nothing on strings your code receives.
- Why does `` navigate(`/users/${id}`) `` compile when `id` is `string`, but `navigate('/users/' + id)` does not?A template string expression whose contextual type is a template literal type is inferred as that template literal type, so the fixed prefix is preserved even though the hole is `string`. The `+` operator has no such rule — string concatenation always produces plain `string`, which is wider than the pattern and therefore not assignable.
- A path arrives from a query parameter. What is the right way to get it into the typed helper?Write a type predicate — `function isUserPath(s: string): s is `/users/${string}`` returning `s.startsWith("/users/")` — and call the helper inside the narrowed branch. The predicate is trusted by the compiler, so its body must genuinely test what it claims. Asserting with `as` compiles too, but performs no check and hides the risk, which for a redirect target is how open-redirect bugs ship.
- Would deriving the route parameters from the path type with recursive `infer` be an improvement here?Sometimes, rarely. It keeps parameter names and the path string in sync automatically, which is worth real money on a large volatile route table. Against it: the work reruns on every keystroke, failures surface as unreadable derived types, and few people on a team can maintain it. For a small route set, a hand-written parameter type beside the route constant is the cheaper contract.
saying these in an interview costs you the question
- Claims the signature validates the path at runtime
- Fixes the concatenation error with an as cast
- Thinks a value typed string is assignable to the pattern
- Says the annotation prevents open redirects on its own
- Assumes an intermediate const keeps the literal type