Infer encode/decode types from the schema - #43
Open
chtitux wants to merge 3 commits into
Open
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
consttype parameter, so an inline schema keeps its literal types.Three conditional types in src/schema/Infer.ts walk those literals:
what decode() returnswhat encode() accepts (DEFAULT fields omittable, RawBytesallowed at any node)
the DecodedNode tree, and stripMetadata() now derives itsreturn 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 stayrequired 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:
inline and
as constschemas are assignable without wideningparameterised by how $ref is resolved
examples in encoding.md/decoding.md were describing a
{ name: value }shapethe codecs stopped producing, and now show
{ key, value }with working hexCo-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01KPw4YEzoKtskJR9n33a1yS