Ahead of the release
npm install @evanion/urn gives you 2.0.0. These pages document main, which has changes that release does not.
Examples
Worked patterns.
One class per namespace
The common shape. A base class fixes the scheme, one subclass per namespace fixes the NID, and no call site ever names a namespace:
import { URN } from '@evanion/urn';
class EcommerceURN extends URN {
static override readonly urn = 'ecommerce';
}
class ProductURN extends EcommerceURN {
static override readonly nid = 'product';
}
class OrderURN extends EcommerceURN {
static override readonly nid = 'order';
}
class CustomerURN extends EcommerceURN {
static override readonly nid = 'customer';
}
ProductURN.stringify('laptop-123'); // 'ecommerce:product:laptop-123'
OrderURN.stringify('456'); // 'ecommerce:order:456'
CustomerURN.stringify('789'); // 'ecommerce:customer:789'
ProductURN.parse('ecommerce:product:laptop-123');
// { urn: 'ecommerce', nid: 'product', nss: 'laptop-123' }Each class only strips its own NID. Hand ProductURN an order and it keeps the
namespace where you can see it:
ProductURN.parse('ecommerce:order:456');
// { urn: 'ecommerce', nid: 'order', nss: 'order:456' }For a payload that mixes namespaces:
function isProduct(urn: string): boolean {
return ProductURN.belongsToNamespace(urn, 'product');
}belongsToNamespace returns false for malformed input rather than throwing,
so it is safe on untrusted strings.
One class, the NID per call
When the set of namespaces is open-ended, a single class takes the NID positionally instead of needing a subclass for each one:
class ResourceURN extends URN {
static override readonly urn = 'resource';
}
ResourceURN.stringify('123', 'user'); // 'resource:user:123'
ResourceURN.stringify('456', 'product'); // 'resource:product:456'
ResourceURN.stringify('789', 'order'); // 'resource:order:789'parse on this class retains every NID in the nss, since none of them is its
own. Use extractId when you want the bare identifier and parse().nid when
you want the namespace:
ResourceURN.parse('resource:user:123').nid; // 'user'
ResourceURN.extractId('resource:user:123'); // '123'A custom separator
A separator other than : takes the class outside RFC 8141, which is a fair
trade when the identifier is internal and has to read as a path:
class DatabaseURN extends URN {
static override readonly urn = 'db';
static override readonly separator = '.';
}
class TableURN extends DatabaseURN {
static override readonly nid = 'table';
}
TableURN.stringify('users'); // 'db.table.users'
TableURN.parse('db.table.users');
// { urn: 'db', nid: 'table', nss: 'users' }The scheme and the NID are then checked against a generic character class with
the separator excluded, rather than against the RFC grammars. db and table
pass; a scheme containing the separator does not — DatabaseURN could not use
the scheme my.db.
Screening untrusted input
import { URN, ValidationError } from '@evanion/urn';
function parseOrNull(input: string) {
return URN.isValidFormat(input) ? URN.parse(input) : null;
}isValidFormat delegates to parse, so the pair can never disagree about what
is valid, and it never throws. Where you want the reason, catch instead:
function describe(input: string): string {
try {
const { nid, nss } = URN.parse(input);
return `${nss} in ${nid}`;
} catch (error) {
if (error instanceof ValidationError) return `not a URN: ${error.message}`;
throw error;
}
}Validating before writing
stringify throws on a part that breaks its grammar, so a batch job that must
not stop on one bad row catches per item:
import { URN, InvalidError } from '@evanion/urn';
function mint(items: { id: string; type: string }[]): string[] {
const urns: string[] = [];
for (const item of items) {
try {
urns.push(URN.stringify(item.id, item.type));
} catch (error) {
if (error instanceof InvalidError) {
console.warn(`skipping ${item.id}: ${error.message}`);
continue;
}
throw error;
}
}
return urns;
}The NSS grammar is RFC 3986’s pchar, which includes !, $, &, ', (,
), *, +, ,, ;, =, : and @:
URN.stringify('product!456', 'product'); // 'urn:product:product!456' — valid
URN.stringify('a b', 'example'); // InvalidError — a space is notThe NID is the strict one: letters and digits with interior hyphens, two to
thirty-two characters. A type field copied straight from a CMS is the value
most likely to fail here.
Identifiers a person types
Anything with a space, an accent or a slash has to be encoded before it is an NSS:
import { URN, encodeNss, decodeNss } from '@evanion/urn';
const title = 'Brass: Birmingham';
const urn = URN.stringify(encodeNss(title), 'game');
// 'urn:game:Brass%3A%20Birmingham'
decodeNss(URN.extractId(urn)); // 'Brass: Birmingham'encodeNss encodes everything outside unreserved, including the : that
would otherwise have been legal, which is what makes its output safe in any
position. Decoding is equally explicit — parse will not do it for you.
Comparing URNs from two sources
Two systems can write the same URN differently and both be right: RFC 8141 makes the scheme and the NID case-insensitive, and the hex digits of a percent-triplet case-insensitive with them.
URN.equals('URN:Example:a123%2cz456', 'urn:example:a123%2Cz456'); // true
URN.equals('urn:example:a123%2Cz456', 'urn:example:a123,z456'); // false
URN.equals('urn:example:A123', 'urn:example:a123'); // falseThe second and third are the ones that matter. An encoded octet is never decoded
for comparison, and the NSS is case-sensitive — both are what the RFC requires,
and both differ from what a naive toLowerCase() comparison would do.
Use equals rather than === anywhere URNs arrive from more than one producer.
Carrying request parameters
The q-component is for parameters meant for the named resource, and equals
ignores it, so a cache keyed on URN equivalence treats these as one entry:
class WeatherURN extends URN {
static override readonly nid = 'example';
}
const base = WeatherURN.stringify({ nss: 'weather', nid: 'example' });
// 'urn:example:weather'
const today = WeatherURN.stringify({
nss: 'weather',
nid: 'example',
qComponent: 'lat=39;lon=-77',
fComponent: 'today',
});
// 'urn:example:weather?=lat=39;lon=-77#today'
WeatherURN.parse(today);
// { urn: 'urn', nid: 'example', nss: 'weather',
// qComponent: 'lat=39;lon=-77', fComponent: 'today' }
URN.equals(base, today); // true — components are excluded from equivalenceSee r-, q- and f-components for the rest.
In a React component
Nothing about the library is environment-specific — it is pure string handling —
but parse throws, so a component rendering untrusted data needs the guard:
import { URN } from '@evanion/urn';
function ResourceRow({ urn }: { urn: string }) {
if (!URN.isValidFormat(urn)) return <li>malformed identifier</li>;
const { nid, nss } = URN.parse(urn);
return (
<li>
<strong>{nid}</strong> {nss}
</li>
);
}In an HTTP handler
A URN in a path segment has to be encoded, because : and / both mean
something to a router. encodeURIComponent on the whole URN is the usual
answer, and it is not the same thing as encodeNss:
import { URN, ValidationError } from '@evanion/urn';
app.get('/resource/:urn', (req, res) => {
try {
const parsed = URN.parse(decodeURIComponent(req.params.urn));
res.json({ namespace: parsed.nid, id: parsed.nss });
} catch (error) {
if (error instanceof ValidationError) {
res.status(400).json({ error: error.message });
return;
}
throw error;
}
});encodeNss makes a string safe as the NSS of a URN. encodeURIComponent makes
a whole URN safe as one segment of a URL. Applying the first where you needed
the second produces a URN that parses and points at the wrong thing.