Skip to Content
URNExamples

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 not

The 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'); // false

The 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 equivalence

See 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.

Last updated on