Skip to content

feat!: infer TypeScript types from pattern guards#3133

Draft
turadg wants to merge 11 commits intomasterfrom
ta/infer-pattern
Draft

feat!: infer TypeScript types from pattern guards#3133
turadg wants to merge 11 commits intomasterfrom
ta/infer-pattern

Conversation

@turadg
Copy link
Member

@turadg turadg commented Mar 22, 2026

closes: #2392
supports: Agoric/agoric-sdk#6160

Summary

Add comprehensive TypeScript type inference from pattern guards (M.* matchers) to exo methods and interfaces. When makeExo, defineExoClass, and defineExoClassKit are called with typed InterfaceGuard values, 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 from M.call() or M.callWhen() guards
  • TypeFromInterfaceGuard<G> — infer method records from interface guard definitions
  • M.remotable<typeof Guard>() — facet-isolated return types with guard polymorphism
  • Per-facet ThisType in defineExoClassKitthis.facets and this.state properly typed

Breaking Changes

  • defineExoClassKit now requires facet method types to match their guard signatures (compile-time check)
  • makeExo and defineExoClass enforce method signatures against guard at compile time
  • Single-facet exos have this.self (not this.facets); multi-facet kits have this.facets (not this.self)

Type System Architecture

  • New types-index.d.ts / types-index.js convention for re-exporting values with enhanced type signatures
  • .ts files (e.g. type-from-pattern.ts) contain type definitions only; no runtime code
  • JSDoc @import for type-only imports in .js files

Documentation

  • AGENTS.md — TypeScript conventions for monorepo contributors
  • Enhanced JSDoc in src/types.d.ts explaining ClassContext vs KitContext
  • Comprehensive type test suite in types.test-d.ts covering all inference cases

Tests

  • Full runtime test coverage for kit facet isolation and this context
  • Type-level tests (tsd) for guard-driven method inference
  • Tests for M.callWhen, M.await, M.promise, and async method returns

turadg and others added 11 commits March 21, 2026 17:56
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>
@changeset-bot
Copy link

changeset-bot bot commented Mar 22, 2026

⚠️ No Changeset found

Latest commit: f1eebed

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@turadg turadg requested a review from gibson042 March 22, 2026 02:33
Comment on lines +329 to +331
symbol: <T extends symbol = symbol>(
limits?: Limits,
) => MatcherOf<'symbol', T>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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')),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are "facet-isolated return types" ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For all of these, does

Suggested change
lt: (rightOperand: Key) => MatcherOf<'lt'>;
lt: (rightOperand: Key) => MatcherOf<'lt', Key>;

make sense? If so, what about

Suggested change
lt: (rightOperand: Key) => MatcherOf<'lt'>;
lt: <K extends Key = Key>(rightOperand: K) => MatcherOf<'lt', K>;

?

Copy link
Member Author

@turadg turadg Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]>;
Copy link
Contributor

@erights erights Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rest is part of the payload. Should it be included in the tuple?

Likewise below.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch!
I'll:

  • Add optional 3rd element: MatcherOf<'splitArray', [Req, Opt, Rest?]> (or use MatcherOf<'splitArray', [Req, Opt] | [Req, Opt, Rest]>)
  • Update TFSplitArray to accept and spread the rest type
  • Same for splitRecord with rest

splitArray: (
required: Pattern[],
optional?: Pattern[],
splitArray: <Req extends Pattern[], Opt extends Pattern[] = []>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious why you did not give Req a default? Likewise below.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'>]>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand the MatcherOf<'kind', 'undefined'>

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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[]>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the return type somehow appear? Likewise for callWhen.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 43 to 44
// 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we there yet?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is special about the zeroth element of a CopyArray?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about empty CopyArrays?

Copy link
Member Author

@turadg turadg Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please keep static concepts distinct from dynamic runtime concepts

Suggested change
* 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am I confused or should this be "can only widen to Key" ?

Copy link
Member Author

@turadg turadg Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! I'll revise to,

Suggested change
// 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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 M type parameters with this exported namespace type M ?

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>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to me to be a fundamental limitation, not a TS limitation. How could a hypothetical better type system surmount this limitation?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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."

Comment on lines +42 to +43
// compile-time error. If TS adds exact types for generics, this
// could be tightened.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be tightened only if your types are cognizant of defaultGuards. If defaultGuards: 'passable', then you must allow "extra" methods.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

@erights erights left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@erights erights self-requested a review March 23, 2026 01:56
@turadg
Copy link
Member Author

turadg commented Mar 23, 2026

Since this is still Draft, I'll stop here for now.

Cool. I'll fixup, repush and prepare for a full review.

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.

It's been our policy not to include static type checks in semver. Claude affirms this:

  • The breaking changes are compile-time only (TS type errors, no JS runtime changes). agoric-sdk's runtime won't break.
  • TypeScript errors require source fixes in agoric-sdk but no behavioral changes.
  • Industry convention (e.g., Zod, fp-ts) treats type-only breaking changes as minor since they don't affect semver (JS runtime).
  • However, endo does ship TS types as part of the package contract. We should probably do a minor bump with a clear CHANGELOG noting the type-level enforcement changes.

So I intend a minor version bump on @endo/patterns and @endo/exo with migration notes via changeset.

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.

patterns: Constructing a pattern should also create a static type

2 participants