Skip to Content
URNGetting Started

Ahead of the release

npm install @evanion/urn gives you 2.0.0. These pages document main, which has changes that release does not.

Getting started

Install

npm install @evanion/urn
yarn add @evanion/urn
pnpm add @evanion/urn

The package is ESM and ships its own types. Import by name:

import { URN, InvalidError } from '@evanion/urn';

Your first URN

import { } from '@evanion/urn'; // A subclass is the extension point: override the statics and every inherited // method reads the new values. class extends { static override readonly = 'trn'; } .('foo', 'bar'); // -> 'trn:bar:foo' // `bar` is not this class's own NID, which is still the inherited `nid`, so // parse keeps it in the nss rather than discarding the namespace. const = .('trn:bar:foo'); // -> { urn: 'trn', nid: 'bar', nss: 'bar:foo' }

Subclass per namespace

The three statics — urn, nid and separator — are what a subclass overrides, and every inherited method reads them off this. A subclass per namespace means the namespace is never an argument at a call site and cannot be mistyped there:

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'; } ProductURN.stringify('laptop-123'); // 'ecommerce:product:laptop-123' OrderURN.stringify('456'); // 'ecommerce:order:456'

TypeScript’s noImplicitOverride requires the override keyword on each one.

The alternative is one class and the NID per call, which is what an open-ended set of namespaces wants:

class ResourceURN extends URN { static override readonly urn = 'resource'; } ResourceURN.stringify('123', 'user'); // 'resource:user:123' ResourceURN.stringify('456', 'product'); // 'resource:product:456'

Parsing keeps a foreign namespace

parse keeps a foreign namespace in the nss. It never discards it.

class UserTRN extends URN { static override readonly urn = 'trn'; static override readonly nid = 'user'; } UserTRN.parse('trn:user:1'); // { urn: 'trn', nid: 'user', nss: '1' } UserTRN.parse('trn:order:42'); // { urn: 'trn', nid: 'order', nss: 'order:42' } UserTRN.parse('ftp:user:1'); // { urn: 'ftp', nid: 'user', nss: 'ftp:user:1' }

A matching scheme and NID give a clean nss. A foreign NID stays attached to it; a foreign scheme keeps the whole original identifier. Either way a record read from another namespace cannot be silently re-labelled as this one.

The base URN class is subject to the same rule, and its own NID is the placeholder 'nid', so almost every namespace is foreign to it:

URN.parse('urn:user:123'); // { urn: 'urn', nid: 'user', nss: 'user:123' } URN.parse('urn:nid:123'); // { urn: 'urn', nid: 'nid', nss: '123' }

Both comparisons are case-folded, per RFC 8141 §3.1, so URN:USER:1 is not foreign to a urn / user class. The parts always come back in the case they were written in.

When you want the trailing identifier regardless of namespace, extractId is the structural answer:

URN.parse('urn:user:123').nss; // 'user:123' URN.extractId('urn:user:123'); // '123'

Validation

Each of the three roles has its own grammar. There is no single flat isValid regex — one character class cannot describe three roles across two separator regimes.

RoleAllowedLength
schemea letter, then letters, digits, +, -, .unbounded
NIDletters and digits, plus - in the interior2–32 writing, 1–32 reading
NSSpchar — letters, digits, -._~!$&'()*+,;=:@, percent-triplets — plus / after the firstunbounded

The NSS set is wider than people expect. !, $, &, ', (, ), *, +, ,, ;, =, : and @ are all sub-delims or pchar under RFC 3986 §3.3 and pass without encoding:

URN.stringify('invalid!character', 'namespace'); // 'urn:namespace:invalid!character' — no error, '!' is a valid NSS character

A space is not:

URN.stringify('a b', 'example'); // InvalidError: NSS contains invalid character ' ' in 'a b'

Read-lenient, write-strict

stringify enforces the RFC 8141 NID exactly. parse accepts the same character set but allows a one-character NID, because RFC 2141 permitted one and RFC 8141 §1 keeps earlier-valid URNs valid. Nothing else is relaxed on read:

URN.parse('urn:x:1'); // { urn: 'urn', nid: 'x', nss: 'x:1' } URN.stringify('1', 'x'); // InvalidError: NID is invalid in 'x': must be at least 2 characters long

Percent-encoding the NSS

stringify works on the wire form and encodes nothing; parse decodes nothing. Both helpers are explicit:

import { , } from '@evanion/urn'; ('café'); // -> 'caf%C3%A9' ('caf%C3%A9'); // -> 'café'

encodeNss encodes everything outside RFC 3986’s unreserved set as UTF-8 percent-triplets with uppercase hex, so its output is safe in any NSS position.

Comparing two URNs

RFC 8141 equivalence is case-insensitive on the scheme and the NID, and exact on the NSS except that the hex digits of a percent-triplet canonicalise to uppercase. An encoded octet is never decoded for comparison:

.('URN:Example:a123%2cz456', 'urn:example:a123%2Cz456'); // -> true .('urn:example:a123%2Cz456', 'urn:example:a123,z456'); // -> false .('urn:example:A123', 'urn:example:a123'); // -> false

equals, sameNamespace and belongsToNamespace all return false for malformed input rather than throwing. parse and extractId throw.

Error handling

import { URN, ValidationError, InvalidError } from '@evanion/urn'; try { return URN.parse(input); } catch (error) { if (error instanceof InvalidError) { // A component was empty, held a disallowed character, or broke a // structural rule. error.property, error.value, error.invalidChar, // error.reason say which. } else if (error instanceof ValidationError) { // The string was not a well-formed URN at all. } throw error; }

InvalidError extends ValidationError, so catching the base covers both. If you only want to know whether a string parses, isValidFormat never throws:

URN.isValidFormat('urn:example:123'); // true URN.isValidFormat('not-a-urn'); // false

It delegates to parse, so the two can never disagree.

Two things that will bite

The statics are unbound. Every one of them reads this, so unlike JSON.stringify they cannot be destructured:

const { stringify } = URN; stringify('a'); // TypeError — the default parameter `nid = this.nid` needs `this`

And the positional arguments to stringify are in the reverse order of parse’s return shape:

URN.stringify(...Object.values(URN.parse('urn:nid:foo'))); // 'foo:nid:urn' URN.stringify(URN.parse('urn:nid:foo')); // 'urn:nid:foo'

Hand stringify the object. Its keys carry the meaning, and only that form can express the r-, q- and f-components.

Last updated on