Skip to Content
URNAPI Reference

Unreleased changes

These pages document main, which has changes 2.1.1 does not. npm install @evanion/urn gives you 2.1.1, documented here. Values on these pages are produced by running the current source on every build.

API reference

@evanion/urn exports one class, two functions, two errors and four types, one heading each below. It needs Node 20 or newer and ships ESM only, which require() loads on Node 20.19, 22.12 or newer.

URN

class

A URN, as defined by RFC 8141, with one deliberate deviation: RFC 8141 requires the literal scheme urn, and this class allows any scheme through the overridable URN.urn static.

Full documentation

The class conforms to RFC 8141 only under the default : separator. A subclass that overrides URN.separator checks the scheme and the NID against a character class that excludes the separator, with no length bounds, and is not RFC 8141 conformant. The NSS keeps the RFC grammar under every separator.

The optional r-, q- and f-components of RFC 8141 2.3 are split off the tail before the separator split, under every separator except one that itself contains ? or #. Such a separator cannot be told apart from a component introducer, so on that subclass parse throws an InvalidError with property: 'NSS' for any ? or # after the NID, and stringify throws one with property: 'COMPONENT' for any component.

Every static reads this, so they cannot be destructured the way JSON.stringify can: const { stringify } = URN then stringify('a') throws a TypeError, because the default parameter nid = this.nid runs with this undefined. Call them on the class.

URN is never instantiated. A subclass overrides the statics in the first three rows. parse, stringify and isValidFormat read all three off this. extractId, equals and sameNamespace read only separator, and belongsToNamespace reads separator and defaults its scheme to the class’s urn. GameURN in the examples on this page is URN with static override readonly nid = 'game'.

StaticDefaultWhat it is
urn'urn'the scheme this class writes and reads as its own
nid'nid'the namespace identifier this class writes and reads as its own
separator':'what the three parts are joined by and split on
schemeGrammarRFC 3986 schemea letter, then letters, digits, +, - and .
nidGrammarRFC 8141 NIDletters, digits and interior -, 2 to 32 characters, checked by stringify
nssGrammarRFC 8141 NSSletters, digits, -._~!$&'()*+,;=:@ and percent-encoded characters, plus / after the first character
rComponentGrammarpchar *(pchar / "/" / "?")the NSS set plus ? after the first character
qComponentGrammarpchar *(pchar / "/" / "?")the same production as the r-component
fComponentGrammarRFC 3986 fragmentthe NSS set plus ?, no first-character rule, and the only one to accept ''

'nid' is a placeholder, not a registered namespace, so every real namespace is foreign to the base URN class. Under a separator other than :, schemeGrammar and nidGrammar become a character class that excludes the separator, with no length bounds. parse accepts a one-character NID, which RFC 2141, the URN syntax RFC 8141 replaced, permitted and nidGrammar refuses:

..('baize'); // -> true ..('game'); // -> true ..('g'); // -> false .('urn:g:azul'); // -> true ..('brass-birmingham'); // -> true ..('brass birmingham'); // -> false

Passed as a bare callback, a static loses this. stringify, parse and extractId throw a TypeError. isValidFormat, equals and sameNamespace catch it and return false. belongsToNamespace throws only when its scheme argument is left out, and returns false under filter or map, which pass a third argument:

const = ['urn:game:azul', 'urn:game:hive']; .(() => .()); // -> ['azul', 'hive'] let = ''; try { .(.); } catch () { = ( as Error).; } ; // -> 'TypeError' .(.); // -> [] .(() => .()); // -> ['urn:game:azul', 'urn:game:hive']
export declare class { static readonly : string; static readonly : string; static readonly : string; static get (): RegExp; static get (): RegExp; static get (): RegExp; static get (): RegExp; static get (): RegExp; static get (): RegExp; protected static get (): RegExp; private static get (); private static get (); private static get (); static (: string): ParsedURN; static (: URNParts): string; static (: string, ?: string, ?: string): string; static (: string): boolean; static (: string): string; static (: string, : string): boolean; static (: string, : string, ?: string): boolean; static (: string, : string): boolean; private static ; private static ; private static ; private static ; private static ; private static ; private static ; private static ; private static ; private static ; }

What the tests state

102 behaviours

stringifies a basic URN
stringifies with a custom NID
rejects a scheme or NID carrying the separator
accepts an NSS carrying the separator
emits an NSS that starts with the NID verbatim
keeps a foreign namespace prefix verbatim in the NSS
accepts RFC 8141's own example NSS
refuses a URN carrying an invalid character
refuses a NID carrying an invalid character
rejects a scheme that does not start with a letter
accepts lengths 2 through 32 on the write path
rejects a 1-character NID on the write path
rejects a 33-character NID on both paths
rejects a leading or trailing hyphen on both paths
rejects '_', '.' and '~' in a NID on both paths
accepts a 1-character NID on the read path only
exposes the write grammar through nidGrammar
accepts the whole pchar set plus slash and percent-triplets
rejects a leading slash
rejects characters outside pchar
accepts the RFC 3986 sub-delims, colon and at-sign
rejects a malformed percent-triplet with a structural reason
neither encodes nor decodes on the write path
returns percent-triplets intact on the read path
follows RFC 3986 under the default separator
round-trips the full pchar set through stringify and parse
round-trips an NSS carrying the separator
round-trips RFC 8141's own example URNs unchanged
is not idempotent, and says so
derives from the URN class
derives from the URN class and sets the separator
derives from the URN class and sets the NID
excludes the separator from the scheme and the NID
drops the RFC length bounds for the scheme and the NID
keeps the RFC pchar grammar for the NSS
round-trips through parse, extractId and isValidFormat
parses a URN into its parts
retains a foreign NID in the NSS
retains a foreign scheme in the NSS, verbatim
distinguishes a foreign scheme from the class scheme
returns the original case
rejects character-invalid input
isValidFormat · returns true for a valid URN
isValidFormat · returns false for an invalid URN
isValidFormat · agrees with parse on every input
extractId · extracts the identifier from a URN
extractId · extracts the identifier when the NSS carries a namespace
extractId · throws on character-invalid input
sameNamespace · returns true for URNs in the same namespace
sameNamespace · folds the case of the scheme and the NID
sameNamespace · returns false for URNs in different namespaces
sameNamespace · returns false for invalid URNs
belongsToNamespace · returns true when the URN belongs to the named namespace
belongsToNamespace · folds the case of the scheme and the NID
belongsToNamespace · returns false when the URN does not belong to the named namespace
belongsToNamespace · returns false for invalid URNs
equals · folds the case of the scheme and the NID
equals · compares the NSS byte for byte
equals · canonicalises percent-triplet hex digits to uppercase
equals · does not treat a percent-triplet as equal to the octet it encodes
equals · is not confused by a foreign NID the parse folded into the NSS
equals · returns false for malformed input rather than throwing
round-trips through decodeNss
throws instead of leaking the string "undefined:" from parse
names the offending input in the parse error
throws a ValidationError, not a bare Error, from extractId
does not report two identical malformed strings as the same namespace
rejects an empty NSS rather than emitting an unparseable URN
rejects an empty NID and URN scheme
does not consider the empty string a valid component
defaults belongsToNamespace to the subclass scheme
still honours an explicit expectedUrn
uses the subclass separator when retaining a foreign NID
does not let the statics be destructured
lets the unbound predicates answer false
parses a component-free URN to exactly three keys
splits each component off the NSS
ends the r-component at the first '?='
takes the f-component off before looking for a '?'
keeps an empty f-component distinct from an absent one
keeps the components out of the NSS on a foreign namespace
emits the components from the object form of stringify
round-trips a URN with components through parse and stringify
rejects a component that breaks its grammar
rejects a bare '?' that introduces nothing
keeps the NSS argument of the positional form free of components
excludes the components from equivalence, per RFC 8141 3.1
drops the components from extractId
exposes the component grammars
parses components under a custom separator
does not parse components when the separator carries a delimiter
refuses a component tail when the separator carries a delimiter
accepts a parsed URN directly
defaults the scheme and the NID to the class
does not invert parse on a foreign namespace
agrees with the positional form
validates the object form the same way
round-trips through the object form, not the spread
exposes InvalidError as a ValidationError
carries a structural reason when no single character is at fault
falls back to a generic message when given neither
lets a single catch handle both error kinds

stringifies a basic URN

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { , } from '@evanion/urn'; class extends { static override readonly = 'game'; } const = .('brass-birmingham'); ; // -> 'urn:game:brass-birmingham' let = ''; try { .('brass birmingham'); } catch () { if ( instanceof ) = .; } ; // -> "NSS contains invalid character ' ' in 'brass birmingham'"

URN.stringify

stringify(parts) and stringify(nss, nid?, urn?) write a URN string. nid and urn default to the calling class’s own statics. Both forms throw InvalidError when a part is empty or breaks its grammar:

.('azul'); // -> 'urn:game:azul' .('azul', 'game'); // -> 'urn:game:azul' .('azul', 'game', 'baize'); // -> 'baize:game:azul' .({ : 'azul', : 'game' }); // -> 'urn:game:azul' .(.('urn:game:azul')); // -> 'urn:game:azul'

The object form’s keys match what parse returns, and only the object form writes the r-, q- and f-components. The positional arguments run in the reverse order of parse’s return, so stringify(...Object.values(parse(x))) puts the scheme where the NSS belongs. stringify writes the NSS verbatim, so it neither deduplicates nor is idempotent:

.('game:azul'); // -> 'urn:game:game:azul' .('urn:game:game:azul').; // -> 'game:azul' .(.('azul')); // -> 'urn:game:urn:game:azul'

URN.parse

parse(urnString) returns a ParsedURN, with every part in the case it was written in and every percent-encoded character intact. A scheme or NID that differs from the class’s own, compared without regard to case, stays in the nss:

.('urn:game:azul'); // -> { urn: 'urn', nid: 'game', nss: 'azul' } .('URN:GAME:azul'); // -> { urn: 'URN', nid: 'GAME', nss: 'azul' } .('urn:order:order-2026-0042'); // -> { urn: 'urn', nid: 'order', nss: 'order:order-2026-0042' } .('baize:game:azul'); // -> { urn: 'baize', nid: 'game', nss: 'baize:game:azul' }

parse throws ValidationError for a string that is not a URN, and InvalidError for a part that breaks its grammar.

URN.isValidFormat

isValidFormat(urnString) returns true when parse would succeed. It calls parse, so the two cannot disagree, and it never throws:

function (: string) { return .() ? .() : null; } ('urn:game:hive'); // -> { urn: 'urn', nid: 'game', nss: 'hive' } ('urn:game:spirit island'); // -> null ('hive'); // -> null ('urn:order:order-2026-0042'); // -> { urn: 'urn', nid: 'order', nss: 'order:order-2026-0042' }

URN.extractId

extractId(urnString) returns everything after the scheme and the NID, with any r-, q- or f-component dropped. On a foreign namespace it differs from parse(urnString).nss, which keeps the NID. The base URN class’s own NID is 'nid', so game is foreign to it:

.('urn:game:azul').; // -> 'game:azul' .('urn:game:azul').; // -> 'game' .('urn:game:azul'); // -> 'azul'

extractId throws ValidationError on malformed input.

URN.sameNamespace

sameNamespace(a, b) returns true when both URNs have the same scheme and the same NID, compared case-folded. Malformed input returns false:

.('urn:game:azul', 'URN:Game:hive'); // -> true .('urn:game:azul', 'urn:order:order-2026-0042'); // -> false .('azul', 'azul'); // -> false

URN.belongsToNamespace

belongsToNamespace(urnString, nid, urn?) returns true when the URN is in the named namespace. urn defaults to the calling class’s own scheme. Malformed input returns false:

.('urn:game:azul', 'game'); // -> true .('baize:game:azul', 'game'); // -> false .('baize:game:azul', 'game', 'baize'); // -> true

URN.equals

equals(a, b) is RFC 8141 §3.1 equivalence. The scheme and the NID fold case. The NSS compares character for character, except that the hex digits of a percent-triplet fold to uppercase. A percent-encoded octet is never decoded, and the r-, q- and f-components are ignored. Malformed input returns false:

.('URN:GAME:azul', 'urn:game:azul'); // -> true .('urn:game:Brass%3a%20Birmingham', 'urn:game:Brass%3A%20Birmingham'); // -> true .('urn:game:Brass%3A%20Birmingham', 'urn:game:Brass:%20Birmingham'); // -> false .('urn:game:Azul', 'urn:game:azul'); // -> false

encodeNss

function

Percent-encodes an arbitrary string into a valid NSS.

Full documentation

Everything outside RFC 3986’s unreserved set is encoded as UTF-8 percent-triplets with uppercase hex, so the result is safe in any position of an NSS. Neither stringify nor parse calls this: encoding is an explicit, separate step, so a value can never be double-encoded by accident.

encodeNss(raw) leaves A-Z a-z 0-9 - . _ ~ alone and percent-encodes every other character, : included.

function (: string): string;

What the tests state

2 behaviours

encodes everything outside the unreserved set
produces an NSS the grammar accepts

encodes everything outside the unreserved set

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { , , } from '@evanion/urn'; .('urn:game:Brass: Birmingham'); // -> false const = ('Brass: Birmingham'); ; // -> 'Brass%3A%20Birmingham' const = .(, 'game'); ; // -> 'urn:game:Brass%3A%20Birmingham' (.()); // -> 'Brass: Birmingham'

decodeNss

function

Decodes the percent-triplets in an NSS back to the raw string.

throws {ValidationError} when the input contains a malformed or truncated percent sequence.

decodeNss(encoded) reverses encodeNss. It throws ValidationError on a % that is not followed by two hex digits, and on triplets that do not decode as UTF-8.

function (: string): string;

What the tests state

2 behaviours

decodes triplets the library itself never produces
throws a ValidationError on a malformed percent sequence

decodes triplets the library itself never produces

Reading the case from the package.

Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.

import { , } from '@evanion/urn'; ('Brass%3A%20Birmingham'); // -> 'Brass: Birmingham' let = ''; try { ('Brass%3'); } catch () { if ( instanceof ) = .; } ; // -> "Malformed percent-encoding in 'Brass%3': '%' must be followed by two hex digits."

ValidationError

error

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

ValidationError is the base class of every error @evanion/urn throws. parse, extractId and decodeNss throw it directly for input that is not a URN or not a valid encoding, so catching it covers InvalidError too.

export declare class extends { constructor(: string); }
import { , , } from '@evanion/urn'; function (: string): string { try { .(); return 'none'; } catch () { if ( instanceof ) return `bad ${.}`; if ( instanceof ) return 'not a URN'; throw ; } } ('urn:game:azul'); // -> 'none' ('urn:game:spirit island'); // -> 'bad NSS' ('azul'); // -> 'not a URN'

InvalidError

error

Thrown when one part of a URN is empty, contains a character outside its role’s permitted set, or breaks a structural rule of its role’s grammar (a length bound, a leading hyphen, a truncated percent-triplet).

InvalidError extends ValidationError, and stringify and parse throw it for one part that is empty or breaks its grammar.

PropertyWhat it holds
propertythe part: 'URN' for the scheme, 'NID', 'NSS', 'R-COMPONENT', 'Q-COMPONENT', 'F-COMPONENT' or 'COMPONENT'
valuethe value of that part
invalidCharthe first character outside the part’s set, when one is at fault
reasonthe structural rule broken when every character is allowed, such as a length bound

'COMPONENT' is the property when a subclass whose separator contains ? or # is asked to write a component.

export declare class extends { constructor(: string, : string, ?: string, ?: string); private static ; readonly : string; readonly : string; readonly ?: string; readonly ?: string; }
import { , } from '@evanion/urn'; function (: string, : string): | undefined { try { .(, ); return ; } catch () { if ( instanceof ) return ; throw ; } } const = ('spirit island', 'game'); ?.; // -> 'NSS' ?.; // -> ' ' ?.; // -> "NSS contains invalid character ' ' in 'spirit island'" const = ('spirit-island', 'g'); ?.; // -> 'NID' ?.; // -> 'must be at least 2 characters long'

ParsedURN

interface

The object returned by URN.parse.

Full documentation

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

The three component keys are absent rather than undefined when the URN carries no components, so such a URN parses to exactly { urn, nid, nss }.

ParsedURN is what parse returns: urn, nid and nss, plus whichever components the URN carries.

interface ParsedURN extends URNComponents { : string; : string; : string; }
.('urn:game:brass-birmingham?=edition=2018#setup'); // -> { urn: 'urn', nid: 'game', nss: 'brass-birmingham', fComponent: 'setup', qComponent: 'edition=2018' }

URNParts

interface

The object form of URN.stringify’s arguments.

Full documentation

The keys match ParsedURN, so a parsed URN can be handed straight back: on one class, stringify(parse(x)) returns x for a URN in that class’s own namespace. The scheme and the NID are optional and fall back to the class’s own, which is what makes { nss } alone valid.

URNParts is what the object form of stringify takes. Only nss is required, and urn and nid fall back to the class’s own. A ParsedURN is assignable to it.

interface URNParts extends URNComponents { ?: string; ?: string; : string; }
import type { ParsedURN, URNParts } from '@evanion/urn'; class extends { static override readonly = 'game'; } const : ParsedURN = .('urn:game:azul#setup'); const : URNParts = { ..., : 'scoring' }; .(); // -> 'urn:game:azul#scoring' const : URNParts = { : 'hive' }; .(); // -> 'urn:game:hive'

URNComponents

interface

The three optional components RFC 8141 2.3 allows after the NSS.

Full documentation

Each is carried in its wire form, without its introducing delimiter and without any percent-decoding. RFC 8141 3.1 excludes all three from URN-equivalence, so URN.equals ignores them.

URNComponents holds rComponent, qComponent and fComponent, each optional. A key is absent when the URN carries no such component, and fComponent may be ''. r-, q- and f-components covers the syntax.

interface URNComponents { ?: string; ?: string; ?: string; }
function ({ , }: URNComponents): string { return [, ].().(' / '); } (.('urn:game:azul?=edition=2017#scoring')); // -> 'edition=2017 / scoring' 'qComponent' in .('urn:game:azul'); // -> false

IFullURN

type

A URN string with known parts, for annotating literals in consumer code.

Full documentation

Assumes the default : separator. A subclass with a custom separator cannot be described by this type.

IFullURN<URN, NID, NSS> is the template literal type `${URN}:${NID}:${NSS}`. stringify returns plain string, because a subclass may override the :.

export type < extends string, extends string, extends string> = `${}:${}:${}`;
import type { } from '@evanion/urn'; type = <'urn', 'game', string>; const : = 'urn:game:azul'; .(); // -> 'azul' // @ts-expect-error: an order URN is not a GameId const : = 'urn:order:order-2026-0042'; .(); // -> true
Last updated on