Skip to content

Commit

Permalink
feat: checked cast with TypedMatcher (#8394)
Browse files Browse the repository at this point in the history
refs: #6160

## Description

I ran again into the need for type narrowing with a shape object: https://github.com/Agoric/agoric-sdk/pull/8385/files#diff-b17d46d065ac769cdf1d70471b16d141f28672965c18522d58d39722d4852ad4R329-R331

endojs/endo#1721 approached the general problem of inferring the type from the shape. But until we have that, we can at least move the type description onto the shape object so users of that shape can automatically get the type a match implies.

With this, the code referenced above would be,
```js
const questionDesc = cast(info, QuestionSpecShape);
```

### Security Considerations

n/a
### Scaling Considerations

n/a

### Documentation Considerations

Once this settles in agoric-sdk, we may want to move it into Endo. Alternately, Endo waits for more general support.

### Testing Considerations

Has a test. Maybe should use tsd instead of Ava

### Upgrade Considerations

n/a
  • Loading branch information
mergify[bot] committed Jul 12, 2024
2 parents 24528f1 + 20e1779 commit 17608dc
Show file tree
Hide file tree
Showing 50 changed files with 299 additions and 140 deletions.
1 change: 1 addition & 0 deletions .prettierrc.json5
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
'packages/store/**/*.js',
'packages/smart-wallet/**/*.js',
'packages/vats/**/*.js',
'packages/vat-data/**/*.js',
],
options: {
plugins: ['prettier-plugin-jsdoc'],
Expand Down
2 changes: 1 addition & 1 deletion packages/ERTP/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,6 @@
"access": "public"
},
"typeCoverage": {
"atLeast": 91.22
"atLeast": 91.23
}
}
6 changes: 4 additions & 2 deletions packages/ERTP/src/paymentLedger.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ import { BrandI, makeIssuerInterfaces } from './typeGuards.js';
/**
* @import {Amount, AssetKind, DisplayInfo, PaymentLedger, Payment, Brand, RecoverySetsOption, Purse, Issuer, Mint} from './types.js'
* @import {ShutdownWithFailure} from '@agoric/swingset-vat'
* @import {Key} from '@endo/patterns';
* @import {TypedPattern} from '@agoric/internal';
*/

/**
* @template {AssetKind} K
* @param {Brand} brand
* @param {AssetKind} assetKind
* @param {K} assetKind
* @param {Pattern} elementShape
* @returns {TypedPattern<Amount<K>>}
*/
const amountShapeFromElementShape = (brand, assetKind, elementShape) => {
let valueShape;
Expand Down
2 changes: 1 addition & 1 deletion packages/SwingSet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,6 @@
"access": "public"
},
"typeCoverage": {
"atLeast": 75.02
"atLeast": 75.1
}
}
2 changes: 1 addition & 1 deletion packages/agoric-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,6 @@
"workerThreads": false
},
"typeCoverage": {
"atLeast": 76.99
"atLeast": 77.3
}
}
2 changes: 1 addition & 1 deletion packages/async-flow/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,6 @@
"workerThreads": false
},
"typeCoverage": {
"atLeast": 77.32
"atLeast": 76.95
}
}
2 changes: 1 addition & 1 deletion packages/base-zone/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,6 @@
"workerThreads": false
},
"typeCoverage": {
"atLeast": 91.11
"atLeast": 91.4
}
}
2 changes: 1 addition & 1 deletion packages/boot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,6 @@
"workerThreads": false
},
"typeCoverage": {
"atLeast": 87.28
"atLeast": 86.66
}
}
2 changes: 1 addition & 1 deletion packages/builders/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,6 @@
"workerThreads": false
},
"typeCoverage": {
"atLeast": 74.36
"atLeast": 76.03
}
}
2 changes: 1 addition & 1 deletion packages/casting/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,6 @@
"workerThreads": false
},
"typeCoverage": {
"atLeast": 88.94
"atLeast": 88.92
}
}
2 changes: 1 addition & 1 deletion packages/cosmic-swingset/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,6 @@
"timeout": "20m"
},
"typeCoverage": {
"atLeast": 80.49
"atLeast": 80.6
}
}
2 changes: 1 addition & 1 deletion packages/deploy-script-support/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,6 @@
"access": "public"
},
"typeCoverage": {
"atLeast": 81.63
"atLeast": 82.44
}
}
2 changes: 1 addition & 1 deletion packages/governance/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,6 @@
"access": "public"
},
"typeCoverage": {
"atLeast": 89.31
"atLeast": 89.35
}
}
5 changes: 2 additions & 3 deletions packages/inter-protocol/src/auction/auctionBook.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
/**
* @import {Baggage} from '@agoric/vat-data';
* @import {PriceAuthority, PriceDescription, PriceQuote, PriceQuoteValue, PriceQuery,} from '@agoric/zoe/tools/types.js';
* @import {TypedPattern} from '@agoric/internal';
*/

const { makeEmpty } = AmountMath;
Expand Down Expand Up @@ -172,9 +173,7 @@ export const prepareAuctionBook = (baggage, zcf, makeRecorderKit) => {

const bookDataKit = makeRecorderKit(
node,
/** @type {import('@agoric/zoe/src/contractSupport/recorder.js').TypedPattern<BookDataNotification>} */ (
M.any()
),
/** @type {TypedPattern<BookDataNotification>} */ (M.any()),
);

return {
Expand Down
5 changes: 2 additions & 3 deletions packages/inter-protocol/src/auction/auctioneer.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { makeScheduler } from './scheduler.js';
import { AuctionState } from './util.js';

/**
* @import {TypedPattern} from '@agoric/internal';
* @import {Baggage} from '@agoric/vat-data';
* @import {PriceAuthority, PriceDescription, PriceQuote, PriceQuoteValue, PriceQuery,} from '@agoric/zoe/tools/types.js';
*/
Expand Down Expand Up @@ -440,9 +441,7 @@ export const start = async (zcf, privateArgs, baggage) => {
const scheduleKit = makeERecorderKit(
E(privateArgs.storageNode).makeChildNode('schedule'),
/**
* @type {import('@agoric/zoe/src/contractSupport/recorder.js').TypedPattern<
* import('./scheduler.js').ScheduleNotification
* >}
* @type {TypedPattern<import('./scheduler.js').ScheduleNotification>}
*/ (M.any()),
);

Expand Down
13 changes: 6 additions & 7 deletions packages/inter-protocol/src/price/fluxAggregatorKit.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import { Far } from '@endo/marshal';
import { prepareOracleAdminKit } from './priceOracleKit.js';
import { prepareRoundsManagerKit } from './roundsManager.js';

/** @import {PriceAuthority, PriceDescription, PriceQuote, PriceQuoteValue, PriceQuery,} from '@agoric/zoe/tools/types.js'; */
/**
* @import {TypedPattern} from '@agoric/internal';
* @import {PriceAuthority, PriceDescription, PriceQuote, PriceQuoteValue, PriceQuery,} from '@agoric/zoe/tools/types.js';
*/

const trace = makeTracer('FlxAgg', true);

Expand Down Expand Up @@ -144,18 +147,14 @@ export const prepareFluxAggregatorKit = async (
priceKit: () =>
makeRecorderKit(
storageNode,
/** @type {import('@agoric/zoe/src/contractSupport/recorder.js').TypedPattern<PriceDescription>} */ (
M.any()
),
/** @type {TypedPattern<PriceDescription>} */ (M.any()),
),
latestRoundKit: () =>
E.when(E(storageNode).makeChildNode('latestRound'), node =>
makeRecorderKit(
node,
/**
* @type {import('@agoric/zoe/src/contractSupport/recorder.js').TypedPattern<
* import('./roundsManager.js').LatestRound
* >}
* @type {TypedPattern<import('./roundsManager.js').LatestRound>}
*/ (M.any()),
),
),
Expand Down
9 changes: 5 additions & 4 deletions packages/inter-protocol/src/psm/psm.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ import { makeNatAmountShape } from '../contractSupport.js';
* given by this contract
*/

/** @import {Baggage} from '@agoric/vat-data' */
/**
* @import {TypedPattern} from '@agoric/internal';
* @import {Baggage} from '@agoric/vat-data'
*/

/** @type {ContractMeta} */
export const meta = {
Expand Down Expand Up @@ -174,9 +177,7 @@ export const start = async (zcf, privateArgs, baggage) => {
E.when(E(privateArgs.storageNode).makeChildNode('metrics'), node =>
makeRecorderKit(
node,
/** @type {import('@agoric/zoe/src/contractSupport/recorder.js').TypedPattern<MetricsNotification>} */ (
M.any()
),
/** @type {TypedPattern<MetricsNotification>} */ (M.any()),
),
),
});
Expand Down
8 changes: 5 additions & 3 deletions packages/inter-protocol/src/reserve/assetReserveKit.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import { UnguardedHelperI } from '@agoric/internal/src/typeGuards.js';

const trace = makeTracer('ReserveKit', true);

/**
* @import {TypedPattern} from '@agoric/internal';
*/

/**
* @typedef {object} MetricsNotification
* @property {AmountKeywordRecord} allocations
Expand Down Expand Up @@ -88,9 +92,7 @@ export const prepareAssetReserveKit = async (
keywordForBrand,
metricsKit: makeRecorderKit(
metricsNode,
/** @type {import('@agoric/zoe/src/contractSupport/recorder.js').TypedPattern<MetricsNotification>} */ (
M.any()
),
/** @type {TypedPattern<MetricsNotification>} */ (M.any()),
),
totalFeeMinted: emptyAmount,
totalFeeBurned: emptyAmount,
Expand Down
8 changes: 5 additions & 3 deletions packages/inter-protocol/src/vaultFactory/vaultDirector.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ import {
provideAndStartVaultManagerKits,
} from './vaultManager.js';

/**
* @import {TypedPattern} from '@agoric/internal';
*/

const trace = makeTracer('VD', true);

/**
Expand Down Expand Up @@ -131,9 +135,7 @@ const prepareVaultDirector = (

const metricsKit = makeERecorderKit(
metricsNode,
/** @type {import('@agoric/zoe/src/contractSupport/recorder.js').TypedPattern<MetricsNotification>} */ (
M.any()
),
/** @type {TypedPattern<MetricsNotification>} */ (M.any()),
);

const managersNode = E(storageNode).makeChildNode('managers');
Expand Down
3 changes: 2 additions & 1 deletion packages/internal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"jessie.js": "^0.3.4"
},
"devDependencies": {
"@endo/exo": "^1.5.0",
"@endo/init": "^1.1.2",
"ava": "^5.3.0",
"tsd": "^0.31.1"
Expand All @@ -56,6 +57,6 @@
"access": "public"
},
"typeCoverage": {
"atLeast": 93.89
"atLeast": 93.78
}
}
1 change: 1 addition & 0 deletions packages/internal/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from './debug.js';
export * from './errors.js';
export * from './utils.js';
export * from './method-tools.js';
export * from './typeCheck.js';
export * from './typeGuards.js';

// eslint-disable-next-line import/export -- just types
Expand Down
23 changes: 23 additions & 0 deletions packages/internal/src/typeCheck.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// @ts-check
import { mustMatch as typelessMustMatch } from '@endo/patterns';

/**
* @import {MustMatch, PatternType, TypedPattern} from './types.js';
*/

/** @type {MustMatch} */
export const mustMatch = typelessMustMatch;

/**
* @template M
* @param {unknown} specimen
* @param {TypedPattern<M>} patt
* @returns {M}
*/
export const cast = (specimen, patt) => {
// mustMatch throws if they don't, which means that `cast` also narrows the
// type but a function can't both narrow and return a type. That is by design:
// https://github.com/microsoft/TypeScript/issues/34636#issuecomment-545025916
mustMatch(specimen, patt);
return specimen;
};
30 changes: 30 additions & 0 deletions packages/internal/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,33 @@ export type Remote<Primary, Local = DataOnly<Primary>> =
export type FarRef<Primary, Local = DataOnly<Primary>> = ERef<
Remote<Primary, Local>
>;

/*
* Stop-gap until https://github.com/Agoric/agoric-sdk/issues/6160
* explictly specify the type that the Pattern will verify through a match.
*
* TODO move all this pattern typing stuff to @endo/patterns
*/
declare const validatedType: unique symbol;
/**
* Tag a pattern with the static type it represents.
*/
export type TypedPattern<T> = Pattern & { [validatedType]?: T };

export declare type PatternType<TM extends TypedPattern<any>> =
TM extends TypedPattern<infer T> ? T : never;

// TODO make Endo's mustMatch do this
/**
* Returning normally indicates success. Match failure is indicated by
* throwing.
*
* Note: remotables can only be matched as "remotable", not the specific kind.
*
* @see {import('@endo/patterns').mustMatch} for the implementation. This one has a type annotation to narrow if the pattern is a TypedPattern.
*/
export declare type MustMatch = <P extends Pattern>(
specimen: unknown,
pattern: P,
label?: string | number,
) => asserts specimen is P extends TypedPattern<any> ? PatternType<P> : unknown;
55 changes: 55 additions & 0 deletions packages/internal/test/typeCheck.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// @ts-check
import test from 'ava';

import { makeExo } from '@endo/exo';
import { M } from '@endo/patterns';
import { cast, mustMatch } from '../src/typeCheck.js';

/**
* @import {TypedPattern} from '@agoric/internal';
* @import {RemotableObject} from '@endo/marshal';
*/

const Mstring = /** @type {TypedPattern<string>} */ (M.string());
const MremotableFoo = /** @type {TypedPattern<RemotableObject<'Foo'>>} */ (
M.remotable('Foo')
);
const MremotableBar = /** @type {TypedPattern<RemotableObject<'Bar'>>} */ (
M.remotable('Bar')
);

const unknownString = /** @type {unknown} */ ('');

test('cast', t => {
// @ts-expect-error unknown type
unknownString.length;
// @ts-expect-error not any
cast(unknownString, Mstring).missing;
cast(unknownString, Mstring).length;
t.pass();
});

test('mustMatch', t => {
// @ts-expect-error unknown type
unknownString.length;
mustMatch(unknownString, Mstring);
unknownString.length;
t.pass();
});

test('remotable', t => {
const maybeFoo = makeExo(`Remotable1`, undefined, {});
mustMatch(maybeFoo, MremotableFoo);
maybeFoo; // narrowed to Foo

const maybeBar = makeExo(`Remotable2`, undefined, {});
mustMatch(maybeBar, MremotableBar);
maybeBar; // narrowed to Bar

mustMatch(maybeFoo, MremotableBar);
maybeFoo; // further narrowed to never
mustMatch(maybeBar, MremotableFoo);
maybeBar; // further narrowed to never

t.pass();
});
2 changes: 1 addition & 1 deletion packages/network/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,6 @@
"workerThreads": false
},
"typeCoverage": {
"atLeast": 89.7
"atLeast": 90.69
}
}
Loading

0 comments on commit 17608dc

Please sign in to comment.