Skip to content

Infer encode/decode types from the schema - #43

Open
chtitux wants to merge 3 commits into
mainfrom
claude/typed-encode-decode-api-a0zy2g
Open

Infer encode/decode types from the schema#43
chtitux wants to merge 3 commits into
mainfrom
claude/typed-encode-decode-api-a0zy2g

Conversation

@chtitux

@chtitux chtitux commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

decode() returned unknown and encode() took unknown, so every caller had to
write an interface by hand and cast. The schema already describes the shape;
this makes TypeScript read it.

SchemaCodec, SchemaBuilder.build and the new createCodec/createCodecs take the
schema as a const type parameter, so an inline schema keeps its literal types.
Three conditional types in src/schema/Infer.ts walk those literals:

  • Infer what decode() returns
  • InferInput what encode() accepts (DEFAULT fields omittable, RawBytes
    allowed at any node)
  • InferMetadata the DecodedNode tree, and stripMetadata() now derives its
    return type from the node type it is given

ENUMERATED narrows to a literal union, CHOICE becomes a discriminated union on
key, OPTIONAL fields become optional properties while DEFAULT fields stay
required after decoding, and $ref resolves through the registry passed to
createCodecs (bounded expansion, so recursive schemas terminate).

This is compile-time only: no validation runs, no dependency is added, and the
emitted JavaScript is unchanged. A schema typed as the wide SchemaNode union —
parsed from JSON or from ASN.1 text — degrades to exactly the unknown-typed API
that existed before.

Supporting changes:

  • SchemaNode moves to src/schema/SchemaNode.ts with readonly collections, so
    inline and as const schemas are assignable without widening
  • Codec gains a TInput parameter, defaulting to T
  • SchemaBuilder's two near-identical builders collapse into one buildNode()
    parameterised by how $ref is resolved
  • examples/typed-api.md documents the mapping and the pitfalls; the CHOICE
    examples in encoding.md/decoding.md were describing a { name: value } shape
    the codecs stopped producing, and now show { key, value } with working hex

Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01KPw4YEzoKtskJR9n33a1yS

claude added 3 commits August 22, 2026 20:08
decode() returned unknown and encode() took unknown, so every caller had to
write an interface by hand and cast. The schema already describes the shape;
this makes TypeScript read it.

SchemaCodec, SchemaBuilder.build and the new createCodec/createCodecs take the
schema as a `const` type parameter, so an inline schema keeps its literal types.
Three conditional types in src/schema/Infer.ts walk those literals:

- Infer<S>        what decode() returns
- InferInput<S>   what encode() accepts (DEFAULT fields omittable, RawBytes
                  allowed at any node)
- InferMetadata<S> the DecodedNode tree, and stripMetadata() now derives its
                  return type from the node type it is given

ENUMERATED narrows to a literal union, CHOICE becomes a discriminated union on
`key`, OPTIONAL fields become optional properties while DEFAULT fields stay
required after decoding, and $ref resolves through the registry passed to
createCodecs (bounded expansion, so recursive schemas terminate).

This is compile-time only: no validation runs, no dependency is added, and the
emitted JavaScript is unchanged. A schema typed as the wide SchemaNode union —
parsed from JSON or from ASN.1 text — degrades to exactly the unknown-typed API
that existed before.

Supporting changes:
- SchemaNode moves to src/schema/SchemaNode.ts with readonly collections, so
  inline and `as const` schemas are assignable without widening
- Codec gains a TInput parameter, defaulting to T
- SchemaBuilder's two near-identical builders collapse into one buildNode()
  parameterised by how $ref is resolved
- examples/typed-api.md documents the mapping and the pitfalls; the CHOICE
  examples in encoding.md/decoding.md were describing a `{ name: value }` shape
  the codecs stopped producing, and now show `{ key, value }` with working hex

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPw4YEzoKtskJR9n33a1yS
The literal-inference API shipped in the previous commit has two weaknesses
that are structural rather than polish: it depends on schemas and values
keeping their literal types, and its errors print deferred conditional-type
aliases (`InferInputChoice<{ readonly type: "CHOICE"; ... }, Record<...>, 12>`)
instead of resolved types. It also gives nothing to a schema parsed from a
.asn file at runtime, which is the library's headline workflow.

Rather than replace it, add two more front-ends over the same interchange
format. SchemaNode is that format: the ASN.1 parser, the DSL and hand-written
literals all produce it; SchemaBuilder and the generator both consume it. All
three now hand back the same TypedCodec<TOut, TIn, TNode>.

asn DSL (src/dsl/) — schemas built from functions, so the types travel in the
value's type parameters instead of being recomputed from literals:

  const Ticket = asn.sequence({
    id: asn.integer({ min: 0, max: 255 }),
    status: asn.enumerated(['pending', 'approved']),
    version: asn.integer({ min: 0, max: 10 }).default(1),
  });

A plain `const` keeps its type, schemas are reusable, and errors name the
offending field. Recursion goes through asn.ref<T>(name) + asn.compile, and
metadata trees stay typed. SEQUENCE field order is object key order, so the
builder rejects integer-like keys — the engine hoists those, which would
silently reorder the encoding rather than fail.

Codegen (src/codegen/, cli/generate-types.ts) — .asn or SchemaNode JSON to a
TypeScript module of named interfaces plus typed codecs. The parser resolves
type references by inlining them, so the generator matches those expanded
structures back to the types they came from; without that the output repeats
whole structures and loses the point of naming them. ASN.1 hyphens are handled
both ways: type names become PascalCase (with collisions numbered and shadowing
avoided), field names stay verbatim and quoted. Each type gets an Input variant
where a DEFAULT is reachable, computed as a fixpoint so $ref cycles terminate.

On a 150-type schema, generated code costs ~20k type instantiations against
~109k for the equivalent inline literal, and errors name the type.

RawBytes: InferInput no longer folds `RawBytes |` into every node. Passthrough
moves to `codec.raw`, which exposes the three encode entry points with every
node widened. The old shape doubled the length of every encode error message
for a feature most values never use.

tests/fixtures/generated/sampleModule.ts is checked-in generator output: a test
asserts the generator still reproduces it byte for byte and round-trips values
through it, and ts-jest compiles it, so generated code cannot silently stop
compiling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPw4YEzoKtskJR9n33a1yS
The two scripts under cli/ were only runnable from a checkout via `npx tsx`,
because the build compiles src/ alone and the package ships dist/. Move them
into src/cli/ so the existing build emits them, and expose one binary with a
subcommand each:

  npx asn1-per-ts types  ticket.asn src/generated/ticket.ts
  npx asn1-per-ts schema ticket.asn ticket.schema.json

The argument handling lives in src/cli/cli.ts behind a CliIo interface, so the
commands are driven by an in-memory IO in tests; src/cli/main.ts is the only
part that touches fs and process. CliIo deliberately uses no Node types, to
keep them out of the published declarations.

Behaviour the old scripts did not have: data goes to stdout and progress to
stderr, so redirecting produces a clean file; failures print a message and exit
1 rather than throwing a stack trace; `--no-runtime` emits types without the
schemas and codecs values.

That last flag exposed a bug worth fixing anyway — the generator emitted its
type imports unconditionally, so a types-only module imported SchemaRegistry
and TypedCodec without using them and failed under noUnusedLocals. Imports are
now emitted only for what the output references.

sideEffects narrows from false to the CLI entry, which does run on import; the
library stays tree-shakeable.

Verified against a real install rather than the checkout: npm pack, install the
tarball into an empty project, run the linked binary, then compile and run the
generated module against the packaged runtime under strict + noUnusedLocals +
NodeNext.

There is no --version flag: reading package.json at runtime needs import.meta
or createRequire, and the root tsconfig type-checks as commonjs where
import.meta is an error. Not worth restructuring for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPw4YEzoKtskJR9n33a1yS
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants