feat!: infer TypeScript types from pattern guards#3133
feat!: infer TypeScript types from pattern guards#3133
Conversation
Add TypeFromPattern<P> — a compile-time utility that infers a TypeScript type from a pattern value, analogous to Zod's z.infer. Key additions: - TypeFromPattern: infers types from all matchers (string, nat, or, and, splitRecord, splitArray, arrayOf, recordOf, mapOf, etc.) - TypeFromMethodGuard: infers function signatures from MethodGuards - TypeFromInterfaceGuard: infers method records from InterfaceGuards - M.infer<typeof pattern>: namespace merge for ergonomic usage - matches(): now a type predicate that narrows in if-blocks - mustMatch(): now an asserts function that narrows after call Type-level breaking changes: - PatternMatchers methods return branded MatcherOf<Tag, Payload> instead of opaque Matcher (enables type inference) - MethodGuard and InterfaceGuard carry type parameters for arg/return guards (enables compile-time checking of exo methods) - matches/mustMatch have narrowing signatures Runtime changes: - getGuardPayloads.js: replace @ts-expect-error with @type casts on legacy adaptors (the expect-error comments became errors themselves because the new types resolved the previously-missing type info) - types-index.js: re-exports M, matches, mustMatch with enhanced type signatures that JSDoc cannot express Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When an InterfaceGuard carries typed MethodGuards (built with M.call(...).returns(...)), makeExo, defineExoClass, and defineExoClassKit now constrain the methods parameter to match the guard's inferred signatures at compile time. Uses TypeFromInterfaceGuard from @endo/patterns to map the guard's method guards to function types, then constrains the methods object to extend those types. Type-level breaking change: makeExo/defineExoClass/defineExoClassKit have new generic signatures. Existing untyped usage (InterfaceGuard without specific MethodGuard types) continues to work via fallback to the broad index-signature type. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Documents the types-index convention, .ts file constraints, and other patterns that AI agents need to follow when working in this repository. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Brand setOf, bagOf, tagged, containerHas, and comparison matchers (lt, lte, eq, neq, gte, gt) with specific MatcherOf<Tag> types instead of opaque Matcher. Add TypeFromPattern support: - Comparison matchers → Key - setOf → CopySet<T>, bagOf → CopyBag<T> - tagged → CopyTagged<Tag> - containerHas → Passable - eref → T | Promise<any>, opt → T | undefined (via existing or branch) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the guard kit carries typed InterfaceGuards, each facet's methods are constrained to match the corresponding guard's inferred signatures at compile time. TS limitation: facet isolation in the return type does not work because Guarded<M> extends Methods (Record<PropertyKey, CallableFunction>), which includes an index signature that makes all property keys valid. Also adds .gitignore entry for packages/exo/types-index.d.ts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use a mapped type in methodsKit so each facet gets its own ThisType scope with facets and state. This ensures this.facets.otherFacet is correctly typed per-facet rather than sharing a single ThisType. Note: kits have this.facets and this.state but NOT this.self (which is only available in non-kit exo classes via makeExo/defineExoClass). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Verify that: - M.interface with no options produces typed InterfaceGuard - M.interface with defaultGuards: undefined produces typed InterfaceGuard - M.interface with defaultGuards: 'passable' produces InterfaceGuard<any> (sloppy mode, methods are unconstrained) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ClassContext (makeExo, defineExoClass) provides this.self — the single exo instance. KitContext (defineExoClassKit) provides this.facets — the record of all facet instances in the cohort. These are mutually exclusive: kits have no self, single-facet exos have no facets. Add JSDoc to ClassContext/KitContext in exo/src/types.d.ts explaining the distinction and when each applies. Add an "Exo this context" section to AGENTS.md so agents don't confuse the two. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ard>()
M.remotable() already accepts a type parameter that defaults to
RemotableObject | RemotableBrand<any, any>. When an InterfaceGuard is
passed as the type parameter, TypeFromPattern now resolves the remotable
to the interface's concrete method types with remotable branding.
This enables facet-isolated return types in exo kits:
const PublicI = M.interface('Public', {
getData: M.call().returns(M.string()),
});
const AdminI = M.interface('Admin', {
getPublic: M.call().returns(M.remotable<typeof PublicI>('Public')),
});
TypeFromMethodGuard of getPublic now infers:
() => { getData: () => string } & RemotableObject & RemotableBrand<{}, any>
instead of the previous generic RemotableObject.
No runtime changes. Unparameterized M.remotable() is unchanged.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…neExoClassKit Covers: - makeExo without guard: return type, this.self typing - makeExo with InterfaceGuard: arg/return inference from guard, @ts-expect-error for wrong arg and wrong return types - defineExoClass without guard: maker return type, this.state/this.self typing - defineExoClass with InterfaceGuard: init types flow to state, guard constraints, @ts-expect-error for wrong return type - defineExoClassKit without guard: per-facet types, this.facets (not this.self), cross-facet access via this.facets - defineExoClassKit with guard kit: per-facet guard constraints, state typing, arg inference - M.remotable<typeof Guard>() in a kit guard: facet-isolated return types, returned value has interface methods + RemotableObject branding - Passable assignability of all exo and kit results - GuardedKit type helper maps facets to Guarded remotables - Documents TS limitation: kit fallback overload prevents guard enforcement for wrong method arg types Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…coverage
Covers the async method guard contract end-to-end:
M.callWhen() observable return:
- M.callWhen().returns(M.string()) → () => Promise<string>
- TypeFromMethodGuard wraps the return in Promise<T> for all callWhen guards
M.await(pattern) unwraps arguments:
- M.callWhen(M.await(M.nat())).returns() → (n: bigint) => Promise<...>
- The implementation receives the resolved value, not a Promise
- Contrast: without M.await, the Promise object passes through as-is
M.promise() as a raw (non-awaited) arg:
- Resolves to Promise<Promise<any>> via TFStructural wrapping Payload
- Documents the known awkwardness: Payload is the full promise type, so
TFStructural produces a double-wrapped type; prefer M.await(M.promise())
when you need to check the resolved value
M.remotable<typeof I>() as an awaited arg:
- M.callWhen(M.await(M.remotable<typeof SourceI>())).returns(M.nat())
- Implementation receives the typed exo object (not a Promise of one)
- Return type: Promise<bigint>
M.remotable<typeof I>() as a callWhen return:
- .returns(M.remotable<typeof ResultI>()) → return type Promise<ExoType>
- Exo type carries the interface's method signatures + RemotableObject branding
makeExo with callWhen guard (end-to-end):
- Implementation uses async to produce Promise<T> (matches constraint)
- Arg types inferred from M.await() guards (bigint, Passable, etc.)
- Observable method types on the exo object are Promise<T>
defineExoClass with callWhen + sync mix:
- callWhen method: counter.increment → (n: bigint) => Promise<undefined>
- sync method: counter.read → () => bigint
defineExoClassKit with callWhen returning a sibling facet:
- loadData: M.callWhen(M.await(M.nat())).returns(M.remotable<typeof DataI>())
- Return type: Promise<{ get: () => string } & RemotableObject>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
| symbol: <T extends symbol = symbol>( | ||
| limits?: Limits, | ||
| ) => MatcherOf<'symbol', T>; |
There was a problem hiding this comment.
No change suggested.
Just noting that I'm glad to be reminded of this, since we're in progress with changing what a passable symbol is.
| * getData: M.call().returns(M.string()), | ||
| * }); | ||
| * const AdminI = M.interface('Admin', { | ||
| * getPublic: M.call().returns(M.remotable<typeof PublicI>('Public')), |
There was a problem hiding this comment.
This is awesome as it expresses what anyone using M.remotable expects to express but still keeps a clear and locally visible separation of what types are enforced/sound and which are not.
There was a problem hiding this comment.
Curious: When using the type parameter, do you expect to also include the string parameter? I ask because I made the mistake of giving remotable an unenforced string parameter but not M.promise. Your type parameterization of these looks similar, which is great. But I wonder whether we should better align them by deprecating the remotable string parameter, or by adding a promise string parameter.
In my tentative opinion, I think both should have a string parameter, as it is useful in runtime diagnostics.
There was a problem hiding this comment.
Agreed, both should have a label string param for runtime diagnostics.
We could add label?: string to M.promise<T>(label?: string) signature to match M.remotable<T>(label?: string). Do you want that in this PR?
| */ | ||
| remotable: (label?: string) => Matcher; | ||
| * | ||
| * For facet-isolated return types in exo kits, pass an InterfaceGuard |
There was a problem hiding this comment.
What are "facet-isolated return types" ?
There was a problem hiding this comment.
In a defineExoClassKit, when facet A's method should return facet B's object, you want the TS return type to be the full B-facet interface, not just bare RemotableObject. By writing M.remotable<typeof PublicI>() in the guard, TypeFromPattern (via TFRemotable) walks the InterfaceGuard and resolves a methods-record type. That's what "facet-isolated" means — each facet's return type is independently typed to its own interface shape.
| * Matches any value that compareKeys reports as less than rightOperand. | ||
| */ | ||
| lt: (rightOperand: Key) => Matcher; | ||
| lt: (rightOperand: Key) => MatcherOf<'lt'>; |
There was a problem hiding this comment.
For all of these, does
| lt: (rightOperand: Key) => MatcherOf<'lt'>; | |
| lt: (rightOperand: Key) => MatcherOf<'lt', Key>; |
make sense? If so, what about
| lt: (rightOperand: Key) => MatcherOf<'lt'>; | |
| lt: <K extends Key = Key>(rightOperand: K) => MatcherOf<'lt', K>; |
?
There was a problem hiding this comment.
The first suggestion MatcherOf<'lt', Key> (explicit Payload = Key) is accurate and we can adopt it for clarity.
The second <K extends Key = Key> would be incorrect semantics. The comparison matchers don't narrow the matched value to the operand's type — M.lt(5n) matches any Key that is less than 5n, which could include strings in the compareKeys ordering,
I'll update lt/lte/eq/neq/gte/gt to MatcherOf<'lt', Key>.
| @@ -454,14 +496,18 @@ export type PatternMatchers = { | |||
| elementPatt?: Pattern, | |||
There was a problem hiding this comment.
No change suggested.
Just checking my understanding: The reason you did not try to parameterize the Pattern is because it would need to be overloaded, and an overloaded type parameter would not be very useful. Yes?
| optional?: [...Opt], | ||
| rest?: Pattern, | ||
| ) => Matcher; | ||
| ) => MatcherOf<'splitArray', [Req, Opt]>; |
There was a problem hiding this comment.
The rest is part of the payload. Should it be included in the tuple?
Likewise below.
There was a problem hiding this comment.
Good catch!
I'll:
- Add optional 3rd element:
MatcherOf<'splitArray', [Req, Opt, Rest?]>(or useMatcherOf<'splitArray', [Req, Opt] | [Req, Opt, Rest]>) - Update
TFSplitArrayto accept and spread the rest type - Same for
splitRecordwithrest
| splitArray: ( | ||
| required: Pattern[], | ||
| optional?: Pattern[], | ||
| splitArray: <Req extends Pattern[], Opt extends Pattern[] = []>( |
There was a problem hiding this comment.
Curious why you did not give Req a default? Likewise below.
There was a problem hiding this comment.
Oversight. I'll add Req extends Pattern[] = Pattern[] default to avoid callers needing to specify it explicitly.
| opt: (subPatt: Pattern) => Pattern; | ||
| opt: <P extends Pattern>( | ||
| subPatt: P, | ||
| ) => MatcherOf<'or', [P, MatcherOf<'kind', 'undefined'>]>; |
There was a problem hiding this comment.
I don't understand the MatcherOf<'kind', 'undefined'>
There was a problem hiding this comment.
opt(P) desugars to M.or(P, M.undefined()). M.undefined() returns MatcherOf<'kind', 'undefined'>. In TFKindMap, key 'undefined' maps to the JS type undefined. So TypeFromPattern for the whole or produces TypeFromPattern<P> | undefined. The MatcherOf<'kind', 'undefined'> is just M.undefined() written in its fully-expanded internal form.
What code comment would help to include? Note that the above was generated by Claude so that's one option for answering the question in the future.
| * automatically hardened and must be at least Passable. | ||
| */ | ||
| call: (...argPatterns: SyncValueGuard[]) => MethodGuardMaker; | ||
| call: <A extends SyncValueGuard[]>( |
There was a problem hiding this comment.
Should the return type somehow appear? Likewise for callWhen.
There was a problem hiding this comment.
The return guard is captured when .returns(RG) is called — MethodGuardMaker<CK, A>.returns<RG>(...) → MethodGuard<CK, A, OptArgs, RG>. At M.call(...) time we only have the arg guards; there is no return guard yet. Fully-typed inference happens only once .returns() is finalized.
| // TODO At such a time that we decide we no longer need to support code | ||
| // preceding https://github.com/endojs/endo/pull/1712 or guard data |
There was a problem hiding this comment.
Not TMK. This PR is purely additive TS types.
| export type TypeFromPattern<P> = | ||
| P extends CopyTagged<`match:${infer K}`, infer Payload> | ||
| ? TFDispatch<K, Payload> | ||
| : P extends readonly [infer H, ...infer T] |
There was a problem hiding this comment.
What is special about the zeroth element of a CopyArray?
There was a problem hiding this comment.
What about empty CopyArrays?
There was a problem hiding this comment.
What is special about the zeroth element of a CopyArray?
This is to handle tuples. The P extends readonly [infer H, ...infer T] branch in TypeFromPattern handles TypeScript tuple literal types, not CopyArray runtime values. When a caller writes [M.string(), M.nat()] (a literal tuple as a pattern), TS infers the type as a tuple [MatcherOf<'string'>, MatcherOf<'nat'>], and this branch maps it element-by-element to [string, bigint]. It's not about CopyArray the data structure — actual M.arrayOf(...) patterns come in as CopyTagged and are handled by the match:arrayOf branch.
What about empty CopyArrays?
An empty tuple [] doesn't match [infer H, ...infer T] (needs at least one element), so it falls through to the identity P branch, returning []. That's correct — an empty pattern tuple matches nothing extra.
|
|
||
| /** | ||
| * Leaf matcher lookup table. | ||
| * These matchers return their Payload directly or a fixed type, with no |
There was a problem hiding this comment.
Please keep static concepts distinct from dynamic runtime concepts
| * These matchers return their Payload directly or a fixed type, with no | |
| * These matcher types return their Payload type directly or a fixed type, with no |
| key: Key; | ||
| pattern: Pattern; | ||
| not: Passable; | ||
| // Comparison matchers — can only narrow to Key |
There was a problem hiding this comment.
Am I confused or should this be "can only widen to Key" ?
There was a problem hiding this comment.
Good catch! I'll revise to,
| // Comparison matchers — can only narrow to Key | |
| // Comparison matchers — can only infer as broad as Key (operand type is not preserved) |
| bigint: bigint; | ||
| string: string; | ||
| symbol: symbol; | ||
| byteArray: ArrayBuffer; |
There was a problem hiding this comment.
No change suggested.
Just noting that we expect to change the type of byteArray to Uint8Array (or possibly readonly Uint8Array ?).
| * `M.infer<typeof pattern>`, analogous to Zod's `z.infer<typeof schema>`. | ||
| */ | ||
| // eslint-disable-next-line @typescript-eslint/no-namespace, no-redeclare, import/export | ||
| export namespace M { |
There was a problem hiding this comment.
I remember when we had both a Remotable type and a Remotable runtime value. The collision kept causing annoyance so we renamed the type to RemotableObject. I thought that since then we try to avoid such collisions. Why is having an M type ok?
There was a problem hiding this comment.
I also see that we're using M as a type parameter in many places. The collision of these with the runtime M seems unproblematic.
What about the collision of M type parameters with this exported namespace type M ?
There was a problem hiding this comment.
I remember when we had both a Remotable type and a Remotable runtime value. The collision kept causing annoyance so we renamed the type to RemotableObject
IIRC, the problem there was they were different shapes. Here, M is the namespace for both.
Why is having an M type ok?
TypeScript allows value + namespace declaration merging: export declare const M: MatcherNamespace (value) and export namespace M { type infer<P> = ... } (namespace) co-exist cleanly. The namespace adds only type-level members (type infer<P>), not runtime-accessible properties. This is different from the Remotable/RemotableObject case where both a class (type + value) and another runtime value shared the same name, causing confusion about which was meant at runtime.
What about the collision of
Mtype parameters with this exported namespace typeM?
TypeScript resolves identifiers by scope. An M extends InterfaceGuard type parameter shadows the namespace M within that generic's scope. Since the namespace M is only used at the type level and only for M.infer<...>, and type parameters named M appear in different scopes (each generic function), there is no runtime collision and the TS compiler correctly distinguishes them.
Worth noting in a comment?
| * } | ||
| * ``` | ||
| */ | ||
| export declare function matches<P extends Pattern>( |
There was a problem hiding this comment.
This is declaring the type of the runtime matches function, right? This is a really cool payoff (likewise with mustMatch below).
I am surprised you can separate a function's type declaration from the function's declaration in this way. But even if we can, is it a good idea?
There was a problem hiding this comment.
This is declaring the type of the runtime matches function, right?
Right.
is it a good idea?
Good question. Claude says: it's the standard pattern for augmenting JS runtime exports with richer TS types than JSDoc can express. The declare function in a .ts / .d.ts file overlays the type seen by consumers without emitting duplicate code. The runtime implementation stays in patternMatchers.js. Many major libraries (Zod, Effect) use this approach. "The key invariant to maintain: the declared signature must be a subtype of the runtime behavior."
And while that invariant would be easier to maintain as a single .ts file, it's a big lift right now because patternMatcher.js has ts-nocheck. I think this is the right approach for the scope of this PR and a separate PR could refactor the .js/.ts and build steps to make that work.
Meanwhile there is one improvement I'll make:
// In types-index.ts — verify runtime impl is assignable to base signature
import { matches as _m } from './patternMatchers.js';
// Widened (non-predicate) base check — if sig drifts this line will error
const _matchesCompat: (specimen: unknown, patt: Pattern) => boolean = _m;This is a lightweight guard that catches any future signature divergence at the types-index boundary, since types-index.js is not @ts-nocheck-ed. The declare function overlay in type-from-pattern.ts is then a safe, intentional narrowing (adding the type predicate) on top of a verified base.
| * | ||
| * When a typed InterfaceGuard is provided (built with `M.call(...).returns(...)`), | ||
| * the `methods` parameter must satisfy the inferred method signatures. | ||
| * The return type preserves the concrete method types from `methods`. |
There was a problem hiding this comment.
This sentence leaves me puzzle about whether the return type in inferred from the concrete method or whether it is inferred by from the guard and enforced on the concrete method. (I hope the latter)
| * }); | ||
| * ``` | ||
| */ | ||
| // TS limitation: When the guard is `InterfaceGuard<any>` (e.g., from |
There was a problem hiding this comment.
This seems to me to be a fundamental limitation, not a TS limitation. How could a hypothetical better type system surmount this limitation?
There was a problem hiding this comment.
You're absolutely right. With Claude's help to see why: when defaultGuards: 'passable', the guard accepts any method, so InterfaceGuard<any> is the correct representation.
I'll reword: "Expected behavior: when the guard uses defaultGuards: 'passable', TypeFromInterfaceGuard produces a broad index-signature, and guard-driven enforcement is intentionally disabled."
| // compile-time error. If TS adds exact types for generics, this | ||
| // could be tightened. |
There was a problem hiding this comment.
It could be tightened only if your types are cognizant of defaultGuards. If defaultGuards: 'passable', then you must allow "extra" methods.
There was a problem hiding this comment.
True. I'll add a TODO to make the strict overload (MakeInterfaceGuardStrict) infer a type that rejects extra methods, while the sloppy overload (MakeInterfaceGuardSloppy) allows them.
|
|
||
| export declare function makeExo<M extends Methods>( | ||
| tag: string, | ||
| interfaceGuard: undefined, |
There was a problem hiding this comment.
Does makeExo(tag, undefined, methods) have the same type as
makeExo(
tag,
M.interface(something, undefined, { defaultGuards: 'passable' },
methods);?
I ask because this is the runtime meaning of omitting the interfaceGuard argument from makeExo. Likewise for all similar overloads below.
There was a problem hiding this comment.
They're semantically equivalent at runtime, but the typed overload with defaultGuards: 'passable' allows weaker static checking. A follow-up could collapse these into one via better overload.
erights
left a comment
There was a problem hiding this comment.
Since this is still Draft, I'll stop here for now.
I look forward to using this!
Will we manage the breakage of agoric-sdk by doing a major version bump on @endo/patterns and @endo/exo? There are no runtime changes so I hope not. But I don't see how to make it work otherwise.
Cool. I'll fixup, repush and prepare for a full review.
It's been our policy not to include static type checks in semver. Claude affirms this:
So I intend a minor version bump on @endo/patterns and @endo/exo with migration notes via changeset. |
closes: #2392
supports: Agoric/agoric-sdk#6160
Summary
Add comprehensive TypeScript type inference from pattern guards (
M.*matchers) to exo methods and interfaces. WhenmakeExo,defineExoClass, anddefineExoClassKitare called with typedInterfaceGuardvalues, method signatures are now inferred from the guard at compile time.Changes
Core Features
TypeFromPattern<P>— infer static types from any pattern matcher (string, bigint, remotable, arrays, records, etc.)TypeFromMethodGuard<G>— infer function signatures fromM.call()orM.callWhen()guardsTypeFromInterfaceGuard<G>— infer method records from interface guard definitionsM.remotable<typeof Guard>()— facet-isolated return types with guard polymorphismThisTypeindefineExoClassKit—this.facetsandthis.stateproperly typedBreaking Changes
defineExoClassKitnow requires facet method types to match their guard signatures (compile-time check)makeExoanddefineExoClassenforce method signatures against guard at compile timethis.self(notthis.facets); multi-facet kits havethis.facets(notthis.self)Type System Architecture
types-index.d.ts/types-index.jsconvention for re-exporting values with enhanced type signatures.tsfiles (e.g.type-from-pattern.ts) contain type definitions only; no runtime codeJSDoc@importfor type-only imports in.jsfilesDocumentation
AGENTS.md— TypeScript conventions for monorepo contributorssrc/types.d.tsexplainingClassContextvsKitContexttypes.test-d.tscovering all inference casesTests
thiscontextM.callWhen,M.await,M.promise, and async method returns