Skip to Content
URNAPI Reference

Ahead of the release

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

API reference

Every export of @evanion/urn. Node 20 or newer; the package is ESM only.

URN

A class whose entire surface is static. Nothing is ever instantiated — URN is a namespace for the operations and a carrier for the three configurable values a subclass overrides.

class URN { static readonly urn: string; // 'urn' static readonly nid: string; // 'nid' static readonly separator: string; // ':' static get schemeGrammar(): RegExp; static get nidGrammar(): RegExp; static get nssGrammar(): RegExp; static get rComponentGrammar(): RegExp; static get qComponentGrammar(): RegExp; static get fComponentGrammar(): RegExp; static stringify(parts: URNParts): string; static stringify(nss: string, nid?: string, urn?: string): string; static parse(urnString: string): ParsedURN; static isValidFormat(urnString: string): boolean; static extractId(urnString: string): string; static sameNamespace(a: string, b: string): boolean; static belongsToNamespace( urnString: string, nid: string, urn?: string, ): boolean; static equals(a: string, b: string): boolean; }

Every static reads this, so none of them can be destructured off the class. const { stringify } = URN; stringify('a') throws a TypeError, because the default parameter nid = this.nid runs with this undefined.

Class properties

StaticDefaultMeaning
urn'urn'the scheme this class writes and recognises as its own
nid'nid'the namespace this class writes and recognises as its own
separator':'what the three parts are joined by and split on

'nid' is a placeholder, not a registered namespace; IANA’s URN namespace registry holds the real ones. Overriding nid is what makes stringify(nss) need no namespace argument, and what lets parse tell its own namespace from a foreign one.

class MyURN extends URN { static override readonly urn = 'my-scheme'; static override readonly nid = 'my-namespace'; } MyURN.stringify('example'); // 'my-scheme:my-namespace:example'

stringify(parts) / stringify(nss, nid?, urn?)

Writes a URN string. nid and urn default to the calling class’s own statics.

URN.stringify('example', 'my-namespace'); // 'urn:my-namespace:example' URN.stringify('example', 'my-namespace', 'custom'); // 'custom:my-namespace:example' URN.stringify({ nss: 'example', nid: 'my-namespace' }); // 'urn:my-namespace:example' URN.stringify({ nss: 'weather', nid: 'example', qComponent: 'lat=39' }); // 'urn:example:weather?=lat=39'

Prefer the object form. Its keys match parse’s return shape, it is the only form that can emit the r-, q- and f-components, and the positional arguments are in the reverse order of what parse returns — so stringify(...Object.values(parse(x))) is silently wrong.

It does not deduplicate. Whatever you pass as the NSS is emitted verbatim after the scheme and the NID:

URN.stringify('my-namespace:example', 'my-namespace'); // 'urn:my-namespace:my-namespace:example'

That is what keeps a composite key imported from another system recoverable: if the repeated segment were dropped, my-namespace:example could never be read back. Name the namespace you meant instead — URN.stringify('example', 'my-namespace').

It is also not idempotent and is not a normaliser:

.(.('foo')); // -> 'urn:nid:urn:nid:foo'

Throws InvalidError when a part is empty or breaks its role’s grammar.

parse(urnString)

Reads a URN into ParsedURN. Nothing is normalised, nothing is decoded: the parts come back in their original case with percent-triplets intact, because RFC 8141 §3.1 requires that an encoded octet stay opaque for equivalence purposes.

URN.parse('urn:nid:example'); // { urn: 'urn', nid: 'nid', nss: 'example' } URN.parse('urn:other-namespace:example'); // { urn: 'urn', nid: 'other-namespace', nss: 'other-namespace:example' } URN.parse('custom:my-namespace:example'); // { urn: 'custom', nid: 'my-namespace', nss: 'custom:my-namespace:example' }

A scheme or NID that differs from the parsing class’s own is retained inside the nss rather than discarded. The base class’s own NID is 'nid', which is why the second and third calls above look the way they do — see the namespace rule.

The read path is lenient in exactly one respect: it accepts a one-character NID, which RFC 2141 permitted.

Throws ValidationError when the string is not a well-formed URN.

isValidFormat(urnString)

true when the string parses. It delegates to parse, so the two cannot drift apart, and it never throws — which makes it the cheap way to screen input before committing to parse.

extractId(urnString)

Everything after the scheme and the NID, with any r-, q- or f-component dropped.

This is deliberately structural and differs from parse(urnString).nss on a foreign namespace: parse keeps a non-matching NID attached so the namespace is not lost, extractId always drops it.

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

Reach for parse when the namespace matters, extractId when you only want the trailing identifier. Throws ValidationError on malformed input.

sameNamespace(a, b)

true when both URNs share a scheme and a NID, compared case-insensitively. Returns false for malformed input rather than throwing.

URN.sameNamespace('urn:user:1', 'URN:User:2'); // true URN.sameNamespace('urn:user:1', 'urn:order:2'); // false

belongsToNamespace(urnString, nid, urn?)

true when the URN is in the given namespace. urn defaults to this class’s own scheme, so a subclass does not repeat it:

class TRN extends URN { static override readonly urn = 'trn'; static override readonly nid = 'bar'; } TRN.belongsToNamespace('trn:bar:foo', 'bar'); // true URN.belongsToNamespace('ftp:user:1', 'user'); // false

equals(a, b)

RFC 8141 §3.1 equivalence. The scheme and the NID fold case; the NSS is compared character for character, except that the hex digits of a percent-triplet canonicalise to uppercase. An encoded octet is never decoded, so %2C and , are not equivalent. The r-, q- and f-components are excluded.

.('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

Returns false for malformed input.

Functions

encodeNss(raw)

Percent-encodes everything outside RFC 3986’s unreserved set (A-Z a-z 0-9 and - . _ ~) as UTF-8 triplets with uppercase hex, so the result is valid in any NSS position.

decodeNss(encoded)

The inverse. Throws ValidationError on a malformed or truncated percent sequence.

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

Neither stringify nor parse calls either of these. Encoding is a separate, explicit step, so a value can never be double-encoded by accident.

Grammars

Six getters, all readable off the class, all reactive to a subclass’s separator:

.; // -> /^[A-Za-z][A-Za-z0-9+.-]*$/ .; // -> /^[A-Za-z0-9][A-Za-z0-9-]{0,30}[A-Za-z0-9]$/ .; // the RFC 8141 `pchar *(pchar / "/")` set .; // `pchar *(pchar / "/" / "?")` .; // the same production .; // RFC 3986 `fragment`, which may be empty

There is no single flat validation regex. One character class cannot describe three roles across two separator regimes; these getters can.

RoleAllowed
schemea letter, then letters, digits, +, -, .
NIDletters and digits, plus - in the interior; 2–32 writing, 1–32 reading
NSSpchar — letters, digits, -._~!$&'()*+,;=:@ and percent-triplets — plus / after the first
r-, q-componentthe NSS set plus ? after the first character
f-componentthe NSS set plus ?, no first-character rule, and may be empty

Everything outside a role’s set must be percent-encoded. nidGrammar is the write grammar; the read path uses an unexported variant with a floor of one character.

Custom separators are not RFC 8141

A subclass that overrides separator gets a generic character class with the separator excluded, for the scheme and the NID, and no RFC length bounds. The NSS keeps the RFC grammar under every separator, because split-then-rejoin on the same delimiter is lossless and the NSS therefore cannot structurally collide with it.

class DatabaseURN extends URN { static override readonly urn = 'db'; static override readonly separator = '.'; } DatabaseURN.stringify('users', 'table'); // 'db.table.users'

A separator containing ? or # also switches component parsing off — see r-, q- and f-components. Such a subclass is not claimed to be RFC 8141 conformant.

Types

ParsedURN

interface ParsedURN extends URNComponents { urn: string; nid: string; nss: string; }

The parts are plain strings and deliberately not type parameters: parse takes a runtime string, so it cannot know their literal types, and free type parameters would let URN.parse<'a', 'b', 'c'>(someString) assert a shape nothing verifies.

URNParts

interface URNParts extends URNComponents { urn?: string; // defaults to the class's own scheme nid?: string; // defaults to the class's own NID nss: string; }

A ParsedURN is assignable to it.

URNComponents

interface URNComponents { rComponent?: string; qComponent?: string; fComponent?: string; }

Absent, not undefined, when the URN carries none.

IFullURN

type IFullURN< URN extends string, NID extends string, NSS extends string, > = `${URN}:${NID}:${NSS}`;

For annotating literals in consumer code:

type UserUrn = IFullURN<'urn', 'user', string>; // `urn:user:${string}` const id: UserUrn = 'urn:user:123';

It assumes the default : separator, so a subclass with a custom one cannot be described by it. stringify returns plain string rather than a template literal type for the same reason.

Errors

ValidationError

Base class for everything the library throws. Catch it to handle any validation failure without naming the subclasses.

InvalidError extends ValidationError

A component was empty, contained a disallowed character, or broke a structural rule of its grammar.

PropertyMeaning
propertywhich role failed: 'URN', 'NID', 'NSS', or a component name
valuethe offending value
invalidCharthe first disallowed character, when one could be identified
reasonwhy it failed when no single character is at fault
URN.stringify('a b', 'example'); // InvalidError: NSS contains invalid character ' ' in 'a b' // property: 'NSS', value: 'a b', invalidChar: ' ' URN.stringify('1', 'x'); // InvalidError: NID is invalid in 'x': must be at least 2 characters long // property: 'NID', value: 'x', reason: 'must be at least 2 characters long'

parse and extractId throw ValidationError; stringify throws InvalidError. Catching ValidationError covers both.

Last updated on