Repository: eemeli/yaml
Pull request #467 (2023-04-21 17:29:51)

easier extending custom collections
Fix: https://github.com/eemeli/yaml/issues/457

Still todo:

- [x] tests for custom collection tags
- [x] update docs with examples of extending YAMLMap and YAMLSeq
- [x] catch the error when you have a tag object that specifies `collection: 'seq'` but gets a `map` token, and vice versa.  (In that case, I imagine it should raise a warning and fall back to `YAMLMap` or `YAMLSeq` as appropriate?)

isaacs: Sure, I can put those methods back. They could just call into Class.from() I guess?
eemeli: > Sure, I can put those methods back. They could just call into Class.from() I guess?

Yeah, that should work.
isaacs: Ok, pushed with updates noted. Also reworded commits to conform with the semantic-commit style here.  PTAL, thanks :)
isaacs: Updated with the comments above.
isaacs: Looks like test failure is due to lacking auth secrets.

Patch:
diff --git a/docs/06_custom_tags.md b/docs/06_custom_tags.md
index c7c6425..9bae8d0 100644
--- a/docs/06_custom_tags.md
+++ b/docs/06_custom_tags.md
@@ -65,7 +65,7 @@ These tags are a part of the YAML 1.1 [language-independent types](https://yaml.
 ## Writing Custom Tags
 
 ```js
-import { stringify } from 'yaml'
+import { YAMLMap, stringify } from 'yaml'
 import { stringifyString } from 'yaml/util'
 
 const regexp = {
@@ -89,18 +89,101 @@ const sharedSymbol = {
   }
 }
 
+class YAMLNullObject extends YAMLMap {
+  tag = '!nullobject'
+  toJSON(_, ctx) {
+    const obj = super.toJSON(_, { ...ctx, mapAsMap: false }, Object)
+    return Object.assign(Object.create(null), obj)
+  }
+}
+
+const nullObject = {
+  tag: '!nullobject',
+  collection: 'map',
+  nodeClass: YAMLNullObject,
+  identify: v => !!(typeof v === 'object' && v && !Object.getPrototypeOf(v))
+}
+
+// slightly more complicated object type
+class YAMLError extends YAMLMap {
+  tag = '!error'
+  toJSON(_, ctx) {
+    const { name, message, stack, ...rest } = super.toJSON(
+      _,
+      { ...ctx, mapAsMap: false },
+      Object
+    )
+    // craft the appropriate error type
+    const Cls =
+      name === 'EvalError'
+        ? EvalError
+        : name === 'RangeError'
+        ? RangeError
+        : name === 'ReferenceError'
+        ? ReferenceError
+        : name === 'SyntaxError'
+        ? SyntaxError
+        : name === 'TypeError'
+        ? TypeError
+        : name === 'URIError'
+        ? URIError
+        : Error
+    if (Cls.name !== name) {
+      Object.defineProperty(er, 'name', {
+        value: name,
+        enumerable: false,
+        configurable: true
+      })
+    }
+    Object.defineProperty(er, 'stack', {
+      value: stack,
+      enumerable: false,
+      configurable: true
+    })
+    return Object.assign(er, rest)
+  }
+
+  static from(schema, obj, ctx) {
+    const { name, message, stack } = obj
+    // ensure these props remain, even if not enumerable
+    return super.from(schema, { ...obj, name, message, stack }, ctx)
+  }
+}
+
+const error = {
+  tag: '!error',
+  collection: 'map',
+  nodeClass: YAMLError,
+  identify: v => !!(typeof v === 'object' && v && v instanceof Error)
+}
+
 stringify(
-  { regexp: /foo/gi, symbol: Symbol.for('bar') },
-  { customTags: [regexp, sharedSymbol] }
+  {
+    regexp: /foo/gi,
+    symbol: Symbol.for('bar'),
+    nullobj: Object.assign(Object.create(null), { a: 1, b: 2 }),
+    error: new Error('This was an error')
+  },
+  { customTags: [regexp, sharedSymbol, nullObject, error] }
 )
 // regexp: !re /foo/gi
 // symbol: !symbol/shared bar
+// nullobj: !nullobject
+//   a: 1
+//   b: 2
+// error: !error
+//   name: Error
+//   message: 'This was an error'
+//   stack: |
+//     at some-file.js:1:3
 ```
 
 In YAML-speak, a custom data type is represented by a _tag_. To define your own tag, you need to account for the ways that your data is both parsed and stringified. Furthermore, both of those processes are split into two stages by the intermediate AST node structure.
 
 If you wish to implement your own custom tags, the [`!!binary`](https://github.com/eemeli/yaml/blob/main/src/schema/yaml-1.1/binary.ts) and [`!!set`](https://github.com/eemeli/yaml/blob/main/src/schema/yaml-1.1/set.ts) tags provide relatively cohesive examples to study in addition to the simple examples in the sidebar here.
 
+Custom collection types (ie, Maps, Sets, objects, and arrays; anything with child properties that may not be propertly serialized to a scalar value) may provide a `nodeClass` property that extends the [`YAMLMap`](https://github.com/eemeli/yaml/blob/main/src/nodes/YAMLMap.ts) and [`YAMLSeq`](https://github.com/eemeli/yaml/blob/main/src/nodes/YAMLSeq.ts) classes, which will be used for parsing and stringifying objects with the specified tag.
+
 ### Parsing Custom Data
 
 At the lowest level, the [`Lexer`](#lexer) and [`Parser`](#parser) will take care of turning string input into a concrete syntax tree (CST).
diff --git a/src/compose/compose-collection.ts b/src/compose/compose-collection.ts
index cb6749a..3cd8e75 100644
--- a/src/compose/compose-collection.ts
+++ b/src/compose/compose-collection.ts
@@ -1,8 +1,8 @@
-import { isMap, isNode } from '../nodes/identity.js'
+import { isNode } from '../nodes/identity.js'
 import type { ParsedNode } from '../nodes/Node.js'
 import { Scalar } from '../nodes/Scalar.js'
-import type { YAMLMap } from '../nodes/YAMLMap.js'
-import type { YAMLSeq } from '../nodes/YAMLSeq.js'
+import { YAMLMap } from '../nodes/YAMLMap.js'
+import { YAMLSeq } from '../nodes/YAMLSeq.js'
 import type {
   BlockMap,
   BlockSequence,
@@ -16,68 +16,106 @@ import { resolveBlockMap } from './resolve-block-map.js'
 import { resolveBlockSeq } from './resolve-block-seq.js'
 import { resolveFlowCollection } from './resolve-flow-collection.js'
 
-export function composeCollection(
+function resolveCollection(
   CN: ComposeNode,
   ctx: ComposeContext,
   token: BlockMap | BlockSequence | FlowCollection,
-  tagToken: SourceToken | null,
-  onError: ComposeErrorHandler
+  onError: ComposeErrorHandler,
+  tagName: string | null,
+  tag?: CollectionTag
 ) {
-  let coll: YAMLMap.Parsed | YAMLSeq.Parsed
-  switch (token.type) {
-    case 'block-map': {
-      coll = resolveBlockMap(CN, ctx, token, onError)
-      break
-    }
-    case 'block-seq': {
-      coll = resolveBlockSeq(CN, ctx, token, onError)
-      break
-    }
-    case 'flow-collection': {
-      coll = resolveFlowCollection(CN, ctx, token, onError)
-      break
-    }
-  }
+  const coll =
+    token.type === 'block-map'
+      ? resolveBlockMap(CN, ctx, token, onError, tag)
+      : token.type === 'block-seq'
+      ? resolveBlockSeq(CN, ctx, token, onError, tag)
+      : resolveFlowCollection(CN, ctx, token, onError, tag)
 
-  if (!tagToken) return coll
-  const tagName = ctx.directives.tagName(tagToken.source, msg =>
-    onError(tagToken, 'TAG_RESOLVE_FAILED', msg)
-  )
-  if (!tagName) return coll
-
-  // Cast needed due to: https://github.com/Microsoft/TypeScript/issues/3841
   const Coll = coll.constructor as typeof YAMLMap | typeof YAMLSeq
+
+  // If we got a tagName matching the class, or the tag name is '!',
+  // then use the tagName from the node class used to create it.
   if (tagName === '!' || tagName === Coll.tagName) {
     coll.tag = Coll.tagName
     return coll
   }
+  if (tagName) coll.tag = tagName
+  return coll
+}
+
+export function composeCollection(
+  CN: ComposeNode,
+  ctx: ComposeContext,
+  token: BlockMap | BlockSequence | FlowCollection,
+  tagToken: SourceToken | null,
+  onError: ComposeErrorHandler
+) {
+  const tagName: string | null = !tagToken
+    ? null
+    : ctx.directives.tagName(tagToken.source, msg =>
+        onError(tagToken, 'TAG_RESOLVE_FAILED', msg)
+      )
+
+  const expType: 'map' | 'seq' =
+    token.type === 'block-map'
+      ? 'map'
+      : token.type === 'block-seq'
+      ? 'seq'
+      : token.start.source === '{'
+      ? 'map'
+      : 'seq'
+
+  // shortcut: check if it's a generic YAMLMap or YAMLSeq
+  // before jumping into the custom tag logic.
+  if (
+    !tagToken ||
+    !tagName ||
+    tagName === '!' ||
+    (tagName === YAMLMap.tagName && expType === 'map') ||
+    (tagName === YAMLSeq.tagName && expType === 'seq') ||
+    !expType
+  ) {
+    return resolveCollection(CN, ctx, token, onError, tagName)
+  }
 
-  const expType = isMap(coll) ? 'map' : 'seq'
   let tag = ctx.schema.tags.find(
-    t => t.collection === expType && t.tag === tagName
+    t => t.tag === tagName && t.collection === expType
   ) as CollectionTag | undefined
+
   if (!tag) {
     const kt = ctx.schema.knownTags[tagName]
     if (kt && kt.collection === expType) {
       ctx.schema.tags.push(Object.assign({}, kt, { default: false }))
       tag = kt
     } else {
-      onError(
-        tagToken,
-        'TAG_RESOLVE_FAILED',
-        `Unresolved tag: ${tagName}`,
-        true
-      )
-      coll.tag = tagName
-      return coll
+      if (kt?.collection) {
+        onError(
+          tagToken,
+          'BAD_COLLECTION_TYPE',
+          `${kt.tag} used for ${expType} collection, but expects ${kt.collection}`,
+          true
+        )
+      } else {
+        onError(
+          tagToken,
+          'TAG_RESOLVE_FAILED',
+          `Unresolved tag: ${tagName}`,
+          true
+        )
+      }
+      return resolveCollection(CN, ctx, token, onError, tagName)
     }
   }
 
-  const res = tag.resolve(
-    coll,
-    msg => onError(tagToken, 'TAG_RESOLVE_FAILED', msg),
-    ctx.options
-  )
+  const coll = resolveCollection(CN, ctx, token, onError, tagName, tag)
+
+  const res =
+    tag.resolve?.(
+      coll,
+      msg => onError(tagToken, 'TAG_RESOLVE_FAILED', msg),
+      ctx.options
+    ) ?? coll
+
   const node = isNode(res)
     ? (res as ParsedNode)
     : (new Scalar(res) as Scalar.Parsed)
diff --git a/src/compose/resolve-block-map.ts b/src/compose/resolve-block-map.ts
index 8e3a495..e0eef21 100644
--- a/src/compose/resolve-block-map.ts
+++ b/src/compose/resolve-block-map.ts
@@ -2,6 +2,7 @@ import type { ParsedNode } from '../nodes/Node.js'
 import { Pair } from '../nodes/Pair.js'
 import { YAMLMap } from '../nodes/YAMLMap.js'
 import type { BlockMap } from '../parse/cst.js'
+import { CollectionTag } from '../schema/types.js'
 import type { ComposeContext, ComposeNode } from './compose-node.js'
 import type { ComposeErrorHandler } from './composer.js'
 import { resolveProps } from './resolve-props.js'
@@ -15,9 +16,11 @@ export function resolveBlockMap(
   { composeNode, composeEmptyNode }: ComposeNode,
   ctx: ComposeContext,
   bm: BlockMap,
-  onError: ComposeErrorHandler
+  onError: ComposeErrorHandler,
+  tag?: CollectionTag
 ) {
-  const map = new YAMLMap<ParsedNode, ParsedNode>(ctx.schema)
+  const NodeClass = tag?.nodeClass ?? YAMLMap
+  const map = new NodeClass(ctx.schema) as YAMLMap<ParsedNode, ParsedNode>
 
   if (ctx.atRoot) ctx.atRoot = false
   let offset = bm.offset
diff --git a/src/compose/resolve-block-seq.ts b/src/compose/resolve-block-seq.ts
index a23cf24..6a1f84e 100644
--- a/src/compose/resolve-block-seq.ts
+++ b/src/compose/resolve-block-seq.ts
@@ -1,5 +1,6 @@
 import { YAMLSeq } from '../nodes/YAMLSeq.js'
 import type { BlockSequence } from '../parse/cst.js'
+import { CollectionTag } from '../schema/types.js'
 import type { ComposeContext, ComposeNode } from './compose-node.js'
 import type { ComposeErrorHandler } from './composer.js'
 import { resolveProps } from './resolve-props.js'
@@ -9,9 +10,11 @@ export function resolveBlockSeq(
   { composeNode, composeEmptyNode }: ComposeNode,
   ctx: ComposeContext,
   bs: BlockSequence,
-  onError: ComposeErrorHandler
+  onError: ComposeErrorHandler,
+  tag?: CollectionTag
 ) {
-  const seq = new YAMLSeq(ctx.schema)
+  const NodeClass = tag?.nodeClass ?? YAMLSeq
+  const seq = new NodeClass(ctx.schema) as YAMLSeq
 
   if (ctx.atRoot) ctx.atRoot = false
   let offset = bs.offset
diff --git a/src/compose/resolve-flow-collection.ts b/src/compose/resolve-flow-collection.ts
index 64d280b..5b418d7 100644
--- a/src/compose/resolve-flow-collection.ts
+++ b/src/compose/resolve-flow-collection.ts
@@ -3,6 +3,8 @@ import { Pair } from '../nodes/Pair.js'
 import { YAMLMap } from '../nodes/YAMLMap.js'
 import { YAMLSeq } from '../nodes/YAMLSeq.js'
 import type { FlowCollection, Token } from '../parse/cst.js'
+import { Schema } from '../schema/Schema.js'
+import { CollectionTag } from '../schema/types.js'
 import type { ComposeContext, ComposeNode } from './compose-node.js'
 import type { ComposeErrorHandler } from './composer.js'
 import { resolveEnd } from './resolve-end.js'
@@ -18,13 +20,15 @@ export function resolveFlowCollection(
   { composeNode, composeEmptyNode }: ComposeNode,
   ctx: ComposeContext,
   fc: FlowCollection,
-  onError: ComposeErrorHandler
+  onError: ComposeErrorHandler,
+  tag?: CollectionTag
 ) {
   const isMap = fc.start.source === '{'
   const fcName = isMap ? 'flow map' : 'flow sequence'
-  const coll = isMap
-    ? (new YAMLMap(ctx.schema) as YAMLMap.Parsed)
-    : (new YAMLSeq(ctx.schema) as YAMLSeq.Parsed)
+  const NodeClass = (tag?.nodeClass ?? (isMap ? YAMLMap : YAMLSeq)) as {
+    new (schema: Schema): YAMLMap.Parsed | YAMLSeq.Parsed
+  }
+  const coll = new NodeClass(ctx.schema)
   coll.flow = true
   const atRoot = ctx.atRoot
   if (atRoot) ctx.atRoot = false
diff --git a/src/doc/createNode.ts b/src/doc/createNode.ts
index 5a61823..ba415a7 100644
--- a/src/doc/createNode.ts
+++ b/src/doc/createNode.ts
@@ -99,6 +99,8 @@ export function createNode(
 
   const node = tagObj?.createNode
     ? tagObj.createNode(ctx.schema, value, ctx)
+    : typeof tagObj?.nodeClass?.from === 'function'
+    ? tagObj.nodeClass.from(ctx.schema, value, ctx)
     : new Scalar(value)
   if (tagName) node.tag = tagName
   else if (!tagObj.default) node.tag = tagObj.tag
diff --git a/src/errors.ts b/src/errors.ts
index 019851f..8865364 100644
--- a/src/errors.ts
+++ b/src/errors.ts
@@ -21,6 +21,7 @@ export type ErrorCode =
   | 'TAB_AS_INDENT'
   | 'TAG_RESOLVE_FAILED'
   | 'UNEXPECTED_TOKEN'
+  | 'BAD_COLLECTION_TYPE'
 
 export type LinePos = { line: number; col: number }
 
diff --git a/src/nodes/YAMLMap.ts b/src/nodes/YAMLMap.ts
index df2fb3f..7485230 100644
--- a/src/nodes/YAMLMap.ts
+++ b/src/nodes/YAMLMap.ts
@@ -2,11 +2,12 @@ import type { BlockMap, FlowCollection } from '../parse/cst.js'
 import type { Schema } from '../schema/Schema.js'
 import type { StringifyContext } from '../stringify/stringify.js'
 import { stringifyCollection } from '../stringify/stringifyCollection.js'
+import { CreateNodeContext } from '../util.js'
 import { addPairToJSMap } from './addPairToJSMap.js'
 import { Collection } from './Collection.js'
 import { isPair, isScalar, MAP } from './identity.js'
 import type { ParsedNode, Range } from './Node.js'
-import { Pair } from './Pair.js'
+import { createPair, Pair } from './Pair.js'
 import { isScalarValue, Scalar } from './Scalar.js'
 import type { ToJSContext } from './toJS.js'
 
@@ -51,6 +52,30 @@ export class YAMLMap<K = unknown, V = unknown> extends Collection {
     super(MAP, schema)
   }
 
+  /**
+   * A generic collection parsing method that can be extended
+   * to other node classes that inherit from YAMLMap
+   */
+  static from(schema: Schema, obj: unknown, ctx: CreateNodeContext) {
+    const { keepUndefined, replacer } = ctx
+    const map = new this(schema)
+    const add = (key: unknown, value: unknown) => {
+      if (typeof replacer === 'function') value = replacer.call(obj, key, value)
+      else if (Array.isArray(replacer) && !replacer.includes(key)) return
+      if (value !== undefined || keepUndefined)
+        map.items.push(createPair(key, value, ctx))
+    }
+    if (obj instanceof Map) {
+      for (const [key, value] of obj) add(key, value)
+    } else if (obj && typeof obj === 'object') {
+      for (const key of Object.keys(obj)) add(key, (obj as any)[key])
+    }
+    if (typeof schema.sortMapEntries === 'function') {
+      map.items.sort(schema.sortMapEntries)
+    }
+    return map
+  }
+
   /**
    * Adds a value to the collection.
    *
diff --git a/src/nodes/YAMLSeq.ts b/src/nodes/YAMLSeq.ts
index 6f45d58..ada8c48 100644
--- a/src/nodes/YAMLSeq.ts
+++ b/src/nodes/YAMLSeq.ts
@@ -1,3 +1,4 @@
+import { createNode, CreateNodeContext } from '../doc/createNode.js'
 import type { BlockSequence, FlowCollection } from '../parse/cst.js'
 import type { Schema } from '../schema/Schema.js'
 import type { StringifyContext } from '../stringify/stringify.js'
@@ -116,6 +117,22 @@ export class YAMLSeq<T = unknown> extends Collection {
       onComment
     })
   }
+
+  static from(schema: Schema, obj: unknown, ctx: CreateNodeContext) {
+    const { replacer } = ctx
+    const seq = new this(schema)
+    if (obj && Symbol.iterator in Object(obj)) {
+      let i = 0
+      for (let it of obj as Iterable<unknown>) {
+        if (typeof replacer === 'function') {
+          const key = obj instanceof Set ? it : String(i++)
+          it = replacer.call(obj, key, it)
+        }
+        seq.items.push(createNode(it, undefined, ctx))
+      }
+    }
+    return seq
+  }
 }
 
 function asItemIndex(key: unknown): number | null {
diff --git a/src/schema/common/map.ts b/src/schema/common/map.ts
index 82159ed..8583d55 100644
--- a/src/schema/common/map.ts
+++ b/src/schema/common/map.ts
@@ -1,38 +1,15 @@
-import type { CreateNodeContext } from '../../doc/createNode.js'
 import { isMap } from '../../nodes/identity.js'
-import { createPair } from '../../nodes/Pair.js'
 import { YAMLMap } from '../../nodes/YAMLMap.js'
 import type { CollectionTag } from '../types.js'
-import type { Schema } from '../Schema.js'
-
-function createMap(schema: Schema, obj: unknown, ctx: CreateNodeContext) {
-  const { keepUndefined, replacer } = ctx
-  const map = new YAMLMap(schema)
-  const add = (key: unknown, value: unknown) => {
-    if (typeof replacer === 'function') value = replacer.call(obj, key, value)
-    else if (Array.isArray(replacer) && !replacer.includes(key)) return
-    if (value !== undefined || keepUndefined)
-      map.items.push(createPair(key, value, ctx))
-  }
-  if (obj instanceof Map) {
-    for (const [key, value] of obj) add(key, value)
-  } else if (obj && typeof obj === 'object') {
-    for (const key of Object.keys(obj)) add(key, (obj as any)[key])
-  }
-  if (typeof schema.sortMapEntries === 'function') {
-    map.items.sort(schema.sortMapEntries)
-  }
-  return map
-}
 
 export const map: CollectionTag = {
   collection: 'map',
-  createNode: createMap,
   default: true,
   nodeClass: YAMLMap,
   tag: 'tag:yaml.org,2002:map',
   resolve(map, onError) {
     if (!isMap(map)) onError('Expected a mapping for this tag')
     return map
-  }
+  },
+  createNode: (schema, obj, ctx) => YAMLMap.from(schema, obj, ctx)
 }
diff --git a/src/schema/common/seq.ts b/src/schema/common/seq.ts
index e908a7d..8bdcab7 100644
--- a/src/schema/common/seq.ts
+++ b/src/schema/common/seq.ts
@@ -1,33 +1,15 @@
-import { CreateNodeContext, createNode } from '../../doc/createNode.js'
 import { isSeq } from '../../nodes/identity.js'
 import { YAMLSeq } from '../../nodes/YAMLSeq.js'
-import type { Schema } from '../Schema.js'
 import type { CollectionTag } from '../types.js'
 
-function createSeq(schema: Schema, obj: unknown, ctx: CreateNodeContext) {
-  const { replacer } = ctx
-  const seq = new YAMLSeq(schema)
-  if (obj && Symbol.iterator in Object(obj)) {
-    let i = 0
-    for (let it of obj as Iterable<unknown>) {
-      if (typeof replacer === 'function') {
-        const key = obj instanceof Set ? it : String(i++)
-        it = replacer.call(obj, key, it)
-      }
-      seq.items.push(createNode(it, undefined, ctx))
-    }
-  }
-  return seq
-}
-
 export const seq: CollectionTag = {
   collection: 'seq',
-  createNode: createSeq,
   default: true,
   nodeClass: YAMLSeq,
   tag: 'tag:yaml.org,2002:seq',
   resolve(seq, onError) {
     if (!isSeq(seq)) onError('Expected a sequence for this tag')
     return seq
-  }
+  },
+  createNode: (schema, obj, ctx) => YAMLSeq.from(schema, obj, ctx)
 }
diff --git a/src/schema/types.ts b/src/schema/types.ts
index b20be45..1bccc0b 100644
--- a/src/schema/types.ts
+++ b/src/schema/types.ts
@@ -1,11 +1,11 @@
 import type { CreateNodeContext } from '../doc/createNode.js'
-import type { Schema } from './Schema.js'
 import type { Node } from '../nodes/Node.js'
 import type { Scalar } from '../nodes/Scalar.js'
 import type { YAMLMap } from '../nodes/YAMLMap.js'
 import type { YAMLSeq } from '../nodes/YAMLSeq.js'
 import type { ParseOptions } from '../options.js'
 import type { StringifyContext } from '../stringify/stringify.js'
+import type { Schema } from './Schema.js'
 
 interface TagBase {
   /**
@@ -92,16 +92,24 @@ export interface CollectionTag extends TagBase {
   /**
    * The `Node` child class that implements this tag.
    * If set, used to select this tag when stringifying.
+   *
+   * If the class provides a static `from` method, then that
+   * will be used if the tag object doesn't have a `createNode` method.
    */
-  nodeClass?: new () => Node
+  nodeClass?: {
+    new (schema?: Schema): Node
+    from?: (schema: Schema, obj: unknown, ctx: CreateNodeContext) => Node
+  }
 
   /**
    * Turns a value into an AST node.
    * If returning a non-`Node` value, the output will be wrapped as a `Scalar`.
+   *
+   * Note: this is required if nodeClass is not provided.
    */
-  resolve(
+  resolve?: (
     value: YAMLMap.Parsed | YAMLSeq.Parsed,
     onError: (message: string) => void,
     options: ParseOptions
-  ): unknown
+  ) => unknown
 }
diff --git a/src/schema/yaml-1.1/omap.ts b/src/schema/yaml-1.1/omap.ts
index 94cc934..f08b8fd 100644
--- a/src/schema/yaml-1.1/omap.ts
+++ b/src/schema/yaml-1.1/omap.ts
@@ -2,6 +2,8 @@ import { isPair, isScalar } from '../../nodes/identity.js'
 import { toJS, ToJSContext } from '../../nodes/toJS.js'
 import { YAMLMap } from '../../nodes/YAMLMap.js'
 import { YAMLSeq } from '../../nodes/YAMLSeq.js'
+import { CreateNodeContext } from '../../util.js'
+import type { Schema } from '../Schema.js'
 import { CollectionTag } from '../types.js'
 import { createPairs, resolvePairs } from './pairs.js'
 
@@ -41,6 +43,13 @@ export class YAMLOMap extends YAMLSeq {
     }
     return map as unknown as unknown[]
   }
+
+  static from(schema: Schema, iterable: unknown, ctx: CreateNodeContext) {
+    const pairs = createPairs(schema, iterable, ctx)
+    const omap = new this()
+    omap.items = pairs.items
+    return omap
+  }
 }
 
 export const omap: CollectionTag = {
@@ -64,11 +73,5 @@ export const omap: CollectionTag = {
     }
     return Object.assign(new YAMLOMap(), pairs)
   },
-
-  createNode(schema, iterable, ctx) {
-    const pairs = createPairs(schema, iterable, ctx)
-    const omap = new YAMLOMap()
-    omap.items = pairs.items
-    return omap
-  }
+  createNode: (schema, iterable, ctx) => YAMLOMap.from(schema, iterable, ctx)
 }
diff --git a/src/schema/yaml-1.1/set.ts b/src/schema/yaml-1.1/set.ts
index e877527..33275a9 100644
--- a/src/schema/yaml-1.1/set.ts
+++ b/src/schema/yaml-1.1/set.ts
@@ -1,10 +1,11 @@
-import type { Schema } from '../../schema/Schema.js'
 import { isMap, isPair, isScalar } from '../../nodes/identity.js'
-import { createPair, Pair } from '../../nodes/Pair.js'
+import { Pair } from '../../nodes/Pair.js'
 import { Scalar } from '../../nodes/Scalar.js'
 import { ToJSContext } from '../../nodes/toJS.js'
-import { YAMLMap, findPair } from '../../nodes/YAMLMap.js'
+import { findPair, YAMLMap } from '../../nodes/YAMLMap.js'
+import type { Schema } from '../../schema/Schema.js'
 import type { StringifyContext } from '../../stringify/stringify.js'
+import { CreateNodeContext, createPair } from '../../util.js'
 import type { CollectionTag } from '../types.js'
 
 export class YAMLSet<T = unknown> extends YAMLMap<T, Scalar<null> | null> {
@@ -84,6 +85,20 @@ export class YAMLSet<T = unknown> extends YAMLMap<T, Scalar<null> | null> {
       )
     else throw new Error('Set items must all have null values')
   }
+
+  static from(schema: Schema, iterable: unknown, ctx: CreateNodeContext) {
+    const { replacer } = ctx
+    const set = new this(schema)
+    if (iterable && Symbol.iterator in Object(iterable))
+      for (let value of iterable as Iterable<unknown>) {
+        if (typeof replacer === 'function')
+          value = replacer.call(iterable, value, value)
+        set.items.push(
+          createPair(value, null, ctx) as Pair<unknown, Scalar<null>>
+        )
+      }
+    return set
+  }
 }
 
 export const set: CollectionTag = {
@@ -92,26 +107,12 @@ export const set: CollectionTag = {
   nodeClass: YAMLSet,
   default: false,
   tag: 'tag:yaml.org,2002:set',
-
+  createNode: (schema, iterable, ctx) => YAMLSet.from(schema, iterable, ctx),
   resolve(map, onError) {
     if (isMap(map)) {
       if (map.hasAllNullValues(true)) return Object.assign(new YAMLSet(), map)
       else onError('Set items must all have null values')
     } else onError('Expected a mapping for this tag')
     return map
-  },
-
-  createNode(schema, iterable, ctx) {
-    const { replacer } = ctx
-    const set = new YAMLSet(schema)
-    if (iterable && Symbol.iterator in Object(iterable))
-      for (let value of iterable as Iterable<unknown>) {
-        if (typeof replacer === 'function')
-          value = replacer.call(iterable, value, value)
-        set.items.push(
-          createPair(value, null, ctx) as Pair<unknown, Scalar<null>>
-        )
-      }
-    return set
   }
 }
diff --git a/src/schema/yaml-1.1/timestamp.ts b/src/schema/yaml-1.1/timestamp.ts
index bfe1f2e..2093f3a 100644
--- a/src/schema/yaml-1.1/timestamp.ts
+++ b/src/schema/yaml-1.1/timestamp.ts
@@ -47,7 +47,7 @@ function stringifySexagesimal(node: Scalar) {
   return (
     sign +
     parts
-      .map(n => (n < 10 ? '0' + String(n) : String(n)))
+      .map(n => String(n).padStart(2, '0'))
       .join(':')
       .replace(/000000\d*$/, '') // % 60 may introduce error
   )


Test patch:
diff --git a/tests/doc/YAML-1.2.spec.ts b/tests/doc/YAML-1.2.spec.ts
index d6c0874..ee9f756 100644
--- a/tests/doc/YAML-1.2.spec.ts
+++ b/tests/doc/YAML-1.2.spec.ts
@@ -1827,8 +1827,8 @@ for (const section in spec) {
           expect(doc.errors.map(err => err.message)).toMatchObject(
             errors?.[i] ?? []
           )
-          expect(doc.warnings.map(err => err.message)).toMatchObject(
-            warnings?.[i] ?? []
+          expect(new Set(doc.warnings.map(err => err.message))).toMatchObject(
+            new Set(warnings?.[i] ?? [])
           )
           for (const err of doc.errors.concat(doc.warnings))
             expect(err).toBeInstanceOf(YAMLError)
diff --git a/tests/doc/types.ts b/tests/doc/types.ts
index b083ebc..c03668b 100644
--- a/tests/doc/types.ts
+++ b/tests/doc/types.ts
@@ -1,4 +1,5 @@
 import {
+  CollectionTag,
   Document,
   DocumentOptions,
   Node,
@@ -14,7 +15,7 @@ import {
   YAMLMap,
   YAMLSeq
 } from 'yaml'
-import { seqTag, stringTag, stringifyString } from 'yaml/util'
+import { seqTag, stringifyString, stringTag } from 'yaml/util'
 import { source } from '../_utils'
 
 const parseDocument = <T extends Node = ParsedNode>(
@@ -491,7 +492,7 @@ description:
     for (let i = 0; i < generic.length; ++i)
       genericStr += String.fromCharCode(generic[i])
     expect(canonicalStr).toBe(genericStr)
-    expect(canonicalStr.substr(0, 5)).toBe('GIF89')
+    expect(canonicalStr.substring(0, 5)).toBe('GIF89')
     expect(String(doc))
       .toBe(`canonical: !!binary "R0lGODlhDAAMAIQAAP//9/X17unp5WZmZgAAAOfn515eXvPz7Y6OjuDg4J\\
   +fn5OTk6enp56enmlpaWNjY6Ojo4SEhP/++f/++f/++f/++f/++f/++f/++f/++f/++f/++f/++f/\\
@@ -981,6 +982,25 @@ describe('custom tags', () => {
     }
   }
 
+  class YAMLNullObject<S, T> extends YAMLMap<S, T> {
+    tag: string = '!nullobject'
+    toJSON(_?: unknown, ctx?: any): any {
+      const obj = super.toJSON<Record<any, any>>(
+        _,
+        { ...(ctx || {}), mapAsMap: false },
+        Object
+      )
+      return Object.assign(Object.create(null), obj)
+    }
+  }
+  const nullObject: CollectionTag = {
+    tag: '!nullobject',
+    collection: 'map',
+    identify: (value: any) =>
+      !!value && typeof value === 'object' && !Object.getPrototypeOf(value),
+    nodeClass: YAMLNullObject
+  }
+
   describe('RegExp', () => {
     test('stringify as plain scalar', () => {
       const str = stringify(/re/g, { customTags: [regexp] })
@@ -1025,6 +1045,21 @@ describe('custom tags', () => {
     })
   })
 
+  test('null prototyped object', () => {
+    const obj = Object.assign(Object.create(null), { x: 'y', z: 1 })
+    const str = stringify(obj, { customTags: [nullObject] })
+    expect(str).toBe('!nullobject\nx: y\nz: 1\n')
+    const res = parse(str, { customTags: [nullObject] })
+    expect(Object.getPrototypeOf(res)).toBe(null)
+    expect(res).toMatchObject(obj)
+  })
+
+  test('cannot parse sequence as nullobject', () => {
+    const str = '!nullobject\n- y\n- 1\n'
+    const doc = origParseDocument(str)
+    expect(doc.warnings).toMatchObject([{ code: 'TAG_RESOLVE_FAILED' }])
+  })
+
   test('array within customTags', () => {
     const obj = { re: /re/g, symbol: Symbol.for('foo') }
     // @ts-expect-error TS should complain here