Skip to content

Commit 7cdfcc5

Browse files
authored
infer TypeScript types from pattern guards (#3133)
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 `defineExoClassKit` — `this.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
2 parents 5ba1bd9 + c63b8b7 commit 7cdfcc5

36 files changed

+3590
-499
lines changed

.changeset/infer-pattern-types.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@endo/patterns': minor
3+
'@endo/exo': minor
4+
---
5+
6+
feat: infer TypeScript types from pattern guards
7+
8+
- `TypeFromPattern<P>` — infer static types from any pattern matcher
9+
- `TypeFromMethodGuard<G>` — infer function signatures from `M.call()` / `M.callWhen()` guards
10+
- `TypeFromInterfaceGuard<G>` — infer method records from interface guard definitions
11+
- `M.remotable<typeof Guard>()` — facet-isolated return types in exo kits
12+
- `M.infer<typeof pattern>` — namespace shorthand analogous to `z.infer`
13+
- `matches` and `mustMatch` now narrow the specimen type via type predicates
14+
- `makeExo`, `defineExoClass`, and `defineExoClassKit` enforce method signatures against guards at compile time
15+
16+
These are compile-time type changes only; there are no runtime behavioral changes.
17+
Existing TypeScript consumers may see new type errors where method signatures diverge from their guards.

.changeset/promise-label-align.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@endo/patterns': minor
3+
---
4+
5+
Add optional `label` parameter to `M.promise()`, aligning its signature
6+
with `M.remotable(label?)`. When a label is provided, runtime error
7+
messages include it for diagnostics (e.g., "Must be a promise Foo, not
8+
remotable").

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ api-docs
101101
!packages/eventual-send/src/exports.d.ts
102102
!packages/eventual-send/src/types.d.ts
103103
!packages/exo/src/types.d.ts
104+
!packages/exo/types-index.d.ts
104105
!packages/far/src/exports.d.ts
105106
!packages/lp32/types.d.ts
106107
!packages/pass-style/src/types.d.ts

AGENTS.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Agent Instructions for endo
2+
3+
This file provides conventions and constraints for AI agents working in this repository.
4+
5+
## Repository structure
6+
7+
- Monorepo managed with Yarn workspaces
8+
- Packages live in `packages/`
9+
- Tests use `ava` (runtime) and `tsd` (types)
10+
- Linting: `eslint` with project-specific rules; run `yarn lint` per-package
11+
12+
## TypeScript usage
13+
14+
Our TypeScript conventions accommodate `.js` development (this repo) and `.ts` consumers (e.g. agoric-sdk). See [agoric-sdk/docs/typescript.md](https://github.com/Agoric/agoric-sdk/blob/master/docs/typescript.md) for full background.
15+
16+
### No `.ts` in runtime bundles
17+
18+
Never use `.ts` files in modules that are transitively imported into an Endo bundle. The Endo bundler does not understand `.ts` syntax. We avoid build steps for runtime imports.
19+
20+
### `.ts` files are for type definitions only
21+
22+
Use `.ts` files to define exported types. These are never imported at runtime. They are made available to consumers through a `types-index` module.
23+
24+
When a `.ts` file contains runtime code (e.g. `type-from-pattern.ts` with `declare` statements), it still produces only `.d.ts` output — the `declare` keyword ensures no JS is emitted. Actual runtime code belongs in `.js` files.
25+
26+
### The `types-index` convention
27+
28+
Each package that exports types uses a pair of files:
29+
30+
- **`types-index.js`** — Runtime re-exports. Contains `export { ... } from './src/foo.js'` for values that need enhanced type signatures (e.g. `M`, `matches`, `mustMatch`).
31+
- **`types-index.d.ts`****Pure re-export index.** Contains only `export type * from` and `export { ... } from` lines. **No type definitions belong here.**
32+
33+
Why: `.d.ts` files are not checked by `tsc` (we use `skipLibCheck: true`). Type definitions in `.d.ts` files silently pass even if they contain errors. Definitions in `.ts` files are checked.
34+
35+
The entrypoint (`index.js`) re-exports from `types-index.js`:
36+
```js
37+
// eslint-disable-next-line import/export
38+
export * from './types-index.js';
39+
```
40+
41+
### Where type definitions go
42+
43+
| What | Where | Why |
44+
|------|-------|-----|
45+
| Interface types, data types | `src/types.ts` | Canonical type definitions |
46+
| Inferred/computed types | `src/type-from-pattern.ts` (or similar `.ts`) | Complex type logic, checked by tsc |
47+
| Value + namespace merges | Same `.ts` file as the namespace | TS requires both in one module for merging |
48+
| `declare function` overrides | `.ts` file alongside related types | Gets type-checked |
49+
| Re-exports only | `types-index.d.ts` | Pure index, no definitions |
50+
51+
### `emitDeclarationOnly`
52+
53+
The repo-wide `tsconfig-build-options.json` sets `emitDeclarationOnly: true`. `tsc` only generates `.d.ts` files, not `.js`. This means `.ts` files with runtime code (not just types) would need `build-ts-to-js` or equivalent — which this repo does not currently have. Keep `.ts` files type-only.
54+
55+
### Imports in `.js` files
56+
57+
Use `/** @import */` JSDoc comments to import types without runtime module loading:
58+
```js
59+
/** @import { Pattern, MatcherNamespace } from './types.js' */
60+
```
61+
62+
## Exo `this` context
63+
64+
Exo methods receive a `this` context (via `ThisType<>`) that differs between single-facet and multi-facet exos:
65+
66+
| API | `this.self` | `this.facets` | `this.state` |
67+
|-----|-------------|---------------|--------------|
68+
| `makeExo` | ✅ the exo instance || ❌ (always `{}`) |
69+
| `defineExoClass` | ✅ the exo instance || ✅ from `init()` |
70+
| `defineExoClassKit` || ✅ all facets in cohort | ✅ from `init()` |
71+
72+
**Why no `self` on kits?** A kit has multiple facets (e.g. `public`, `admin`), each a separate remotable object. There is no single "self". Use `this.facets.facetName` to access any facet in the cohort.
73+
74+
When writing `ThisType<>` annotations in `types-index.d.ts`:
75+
- Single-facet: `ThisType<{ self: Guarded<M>; state: S }>`
76+
- Multi-facet: `ThisType<{ facets: GuardedKit<F>; state: S }>`
77+
78+
Never mix `self` and `facets` in the same context type.
79+
80+
## Testing
81+
82+
- Runtime tests: `yarn test` (uses `ava`)
83+
- Type tests: `yarn lint:types` (uses `tsd` — test files are `test/types.test-d.ts`)
84+
- Lint: `yarn lint` (runs both `lint:types` and `lint:eslint`)
85+
86+
Always run `yarn lint` in each package you've modified before committing.
87+
88+
## Commit conventions
89+
90+
- Use conventional commits: `feat(pkg):`, `fix(pkg):`, `refactor(pkg):`, `chore:`, `test(pkg):`
91+
- Breaking changes: `feat(pkg)!:` or `fix(pkg)!:`
92+
- File conversions (`.js` to `.ts`) get their own `refactor:` commit

packages/common/object-meta-map.js

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ const { ownKeys } = Reflect;
2727
*
2828
* @template {Record<PropertyKey, any>} O
2929
* @param {O} original
30-
* @param {(
31-
* desc: TypedPropertyDescriptor<O[keyof O]>,
32-
* key: keyof O
30+
* @param {<K extends keyof O>(
31+
* desc: TypedPropertyDescriptor<O[K]>,
32+
* key: K
3333
* ) => (PropertyDescriptor | undefined)} metaMapFn
3434
* @param {any} [proto]
3535
* @returns {any}
@@ -41,11 +41,17 @@ export const objectMetaMap = (
4141
) => {
4242
const descs = getOwnPropertyDescriptors(original);
4343
const keys = ownKeys(original);
44+
/**
45+
* Preserve the per-key descriptor type when calling `metaMapFn`.
46+
*
47+
* @template {keyof O} K
48+
* @param {K} key
49+
* @returns {[K, PropertyDescriptor | undefined]}
50+
*/
51+
const mapDesc = key => [key, metaMapFn(descs[key], key)];
4452

4553
const descEntries = /** @type {[PropertyKey,PropertyDescriptor][]} */ (
46-
keys
47-
.map(key => [key, metaMapFn(descs[key], key)])
48-
.filter(([_key, optDesc]) => optDesc !== undefined)
54+
keys.map(mapDesc).filter(([_key, optDesc]) => optDesc !== undefined)
4955
);
5056
return harden(create(proto, fromEntries(descEntries)));
5157
};

packages/eslint-plugin/lib/configs/style.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ module.exports = {
6363
rules: {
6464
'import/no-unresolved': 'off',
6565
'no-unused-vars': 'off',
66+
'jsdoc/require-param-type': 'off',
6667
},
6768
},
6869
],

packages/exo/docs/types.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Exo types
2+
3+
Exos have runtime guards and also static type annotations. Both are optional, leading to this matrix of behaviors:
4+
5+
| Impl | Unguarded | Guarded |
6+
| -- | -- | -- |
7+
| **plain** | inferred from JS | guard wins |
8+
| **typed** | impl wins | compatibility check[^1][^2] |
9+
10+
[^1]: We pick the impl type because it has the param names and the guard doesn't.
11+
[^2]: Use `GuardedMethods<typeof exo>` to opt into the guard's type contract (e.g. `.optional()` params). Parameter names are not preserved (TS limitation).
12+

packages/exo/index.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
export * from './src/exo-makers.js';
1+
export { initEmpty } from './src/exo-makers.js';
2+
3+
// makeExo, defineExoClass, defineExoClassKit are re-exported from
4+
// types-index so they get typed declarations that infer method types
5+
// from InterfaceGuard (see types-index.d.ts).
6+
// eslint-disable-next-line import/export
7+
export * from './types-index.js';
28

39
// eslint-disable-next-line import/export -- ESLint not aware of type exports in types.d.ts
410
export * from './src/types.js';

packages/exo/src/types.d.ts

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import type { RemotableBrand } from '@endo/eventual-send';
22
import type { RemotableObject, RemotableMethodName } from '@endo/pass-style';
3-
import type { InterfaceGuard, MethodGuard, Pattern } from '@endo/patterns';
3+
import type {
4+
InterfaceGuard,
5+
MethodGuard,
6+
Pattern,
7+
TypeFromMethodGuard,
8+
} from '@endo/patterns';
49
import type { GetInterfaceGuard } from './get-interface.js';
510

611
export type MatchConfig = {
@@ -12,10 +17,39 @@ export type MatchConfig = {
1217
};
1318
export type FacetName = string;
1419
export type Methods = Record<RemotableMethodName, CallableFunction>;
20+
/**
21+
* The `this` context for methods of a single-facet exo (makeExo, defineExoClass).
22+
*
23+
* - `this.state` — the sealed object returned by `init()`. For
24+
* `defineExoClass`, `S` is `ReturnType<init>` (typically a plain
25+
* object like `{ count: number }`). For `makeExo` (which has no
26+
* `init`), `S` is `{}` — an empty object with no accessible state.
27+
* - `this.self` — the exo instance itself (the object whose methods you're
28+
* implementing). Useful for passing "yourself" to other code.
29+
*
30+
* **Not available on kits.** Multi-facet exos use {@link KitContext} instead,
31+
* which provides `this.facets` (the record of all facet instances in the
32+
* cohort) rather than `this.self`.
33+
*/
1534
export type ClassContext<S = any, M extends Methods = any> = {
1635
state: S;
1736
self: M;
1837
};
38+
39+
/**
40+
* The `this` context for methods of a multi-facet exo kit (defineExoClassKit).
41+
*
42+
* - `this.state` — the sealed object returned by `init()`.
43+
* `S` is `ReturnType<init>`, typically a plain object.
44+
* - `this.facets` — the record of all facet instances in this cohort,
45+
* keyed by facet name. Use `this.facets.myFacet` to access sibling
46+
* facets.
47+
*
48+
* **No `this.self` on kits.** A kit method belongs to one facet, and
49+
* there is no single "self" — instead, each facet is a separate remotable
50+
* object. Use `this.facets.foo` to get the specific facet you need.
51+
* For single-facet exos, see {@link ClassContext} which provides `this.self`.
52+
*/
1953
export type KitContext<S = any, F extends Record<string, Methods> = any> = {
2054
state: S;
2155
facets: F;
@@ -114,11 +148,94 @@ export type FarClassOptions<C, F = any> = {
114148
export type Farable<M extends Methods> = M &
115149
RemotableBrand<{}, M> &
116150
RemotableObject;
117-
export type Guarded<M extends Methods> = Farable<M & GetInterfaceGuard<M>>;
118-
export type GuardedKit<F extends Record<string, Methods>> = {
119-
[K in keyof F]: Guarded<F[K]>;
151+
/**
152+
* Strip index-signature keys from a type, keeping only concrete known keys.
153+
* This prevents `Record<PropertyKey, CallableFunction>` (from the `Methods`
154+
* constraint) from leaking an index signature into `Guarded<M>`, which would
155+
* make any property access (e.g. `exo.nonExistentMethod`) silently resolve
156+
* to `CallableFunction` instead of being a type error.
157+
*
158+
* Special cases:
159+
* - When `T` is `any`, pass through unchanged (avoids collapsing to `{}`).
160+
* - When `T` has only index-signature keys and no concrete keys (e.g. bare
161+
* `Methods` from untyped JS), pass through unchanged so that property
162+
* access still works.
163+
* - When `T` has concrete keys mixed with an index signature (e.g.
164+
* `{ incr: ... } & Methods`), strip the index signature and keep only
165+
* the concrete keys.
166+
*/
167+
type StripIndexCore<T> = {
168+
[K in keyof T as string extends K
169+
? never
170+
: number extends K
171+
? never
172+
: symbol extends K
173+
? never
174+
: K]: T[K];
175+
};
176+
type StripIndexSignature<T> = 0 extends 1 & T
177+
? T // T is any
178+
: keyof StripIndexCore<T> extends never
179+
? T // no concrete keys (e.g. bare Methods) — keep as-is
180+
: StripIndexCore<T>;
181+
/**
182+
* The second type parameter `G` embeds the specific InterfaceGuard type
183+
* into the `__getInterfaceGuard__` method's return type. This enables
184+
* {@link GuardedMethods} to extract guard-inferred method signatures
185+
* from a Guarded exo object. When no guard is provided (unguarded
186+
* overloads), `G` defaults to a generic InterfaceGuard keyed by M.
187+
*/
188+
export type Guarded<
189+
M extends Methods,
190+
G extends InterfaceGuard = InterfaceGuard<{
191+
[K in keyof M]: MethodGuard;
192+
}>,
193+
> = StripIndexSignature<M> & {
194+
__getInterfaceGuard__?: () => G | undefined;
195+
} & RemotableBrand<{}, M> &
196+
RemotableObject;
197+
export type GuardedKit<
198+
F extends Record<string, Methods>,
199+
GK extends Record<string, InterfaceGuard> = {
200+
[K in keyof F]: InterfaceGuard<{ [M in keyof F[K]]: MethodGuard }>;
201+
},
202+
> = {
203+
[K in keyof F as string extends K ? never : K]: Guarded<
204+
F[K],
205+
K extends keyof GK ? GK[K] : InterfaceGuard
206+
>;
120207
};
121208

209+
/**
210+
* Extract guard-inferred method types from a Guarded exo object.
211+
*
212+
* By default, `Guarded<M>` uses the implementation's types (preserving
213+
* parameter names). `GuardedMethods` re-derives the method signatures
214+
* from the exo's embedded InterfaceGuard (via `__getInterfaceGuard__`),
215+
* giving callers the guard's type contract — including `.optional()`
216+
* parameters that the implementation may declare as required.
217+
*
218+
* Note: parameter names are NOT preserved — TypeScript has no mechanism
219+
* to programmatically copy parameter names between function types.
220+
* The types are correct but displayed as `args_0`, `args_1`, etc.
221+
*
222+
* @example
223+
* ```ts
224+
* const counter = makeExo('Counter', CounterI, { incr(n) { ... } });
225+
* type CM = GuardedMethods<typeof counter>;
226+
* // { incr: (args_0?: bigint) => bigint } — optional from guard
227+
* ```
228+
*/
229+
export type GuardedMethods<E> = E extends {
230+
__getInterfaceGuard__?: () => InterfaceGuard<infer MG> | undefined;
231+
}
232+
? {
233+
[K in keyof MG as K extends keyof E ? K : never]: TypeFromMethodGuard<
234+
MG[K]
235+
>;
236+
}
237+
: never;
238+
122239
/**
123240
* Rearrange the Exo types to make a cast of the methods (M) and init function (I) to a specific type.
124241
*/

packages/exo/test/exo-class-js-class.test.js

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
// @ts-nocheck
21
/* eslint-disable max-classes-per-file */
32
/* eslint-disable class-methods-use-this */
43
import test from '@endo/ses-ava/test.js';
@@ -18,7 +17,11 @@ const DoublerI = M.interface('Doubler', {
1817
double: M.call(M.lte(10)).returns(M.number()),
1918
});
2019

21-
const doubler = makeExo('doubler', DoublerI, DoublerBehaviorClass.prototype);
20+
const doubler = makeExo(
21+
'doubler',
22+
DoublerI,
23+
Object.create(DoublerBehaviorClass.prototype),
24+
);
2225

2326
test('exo doubler using js classes', t => {
2427
t.is(passStyleOf(doubler), 'remotable');
@@ -37,6 +40,10 @@ test('exo doubler using js classes', t => {
3740

3841
// Based on FarSubclass2 in test-far-class-instances.js
3942
class DoubleAdderBehaviorClass extends DoublerBehaviorClass {
43+
/**
44+
* @param {number} x
45+
* @this {import('../src/types.js').ClassContext<{ y: number }, { double: (x: number) => number }>}
46+
*/
4047
doubleAddSelfCall(x) {
4148
const {
4249
state: { y },
@@ -45,6 +52,10 @@ class DoubleAdderBehaviorClass extends DoublerBehaviorClass {
4552
return self.double(x) + y;
4653
}
4754

55+
/**
56+
* @param {number} x
57+
* @this {import('../src/types.js').ClassContext<{ y: number }, { double: (x: number) => number }>}
58+
*/
4859
doubleAddSuperCall(x) {
4960
const {
5061
state: { y },
@@ -63,7 +74,7 @@ const makeDoubleAdder = defineExoClass(
6374
'doubleAdderClass',
6475
DoubleAdderI,
6576
y => ({ y }),
66-
DoubleAdderBehaviorClass.prototype,
77+
Object.create(DoubleAdderBehaviorClass.prototype),
6778
);
6879

6980
test('exo inheritance self vs super call', t => {

0 commit comments

Comments
 (0)