From 94e2ff6eafa333e85314e839edc9d1bb6f1472e7 Mon Sep 17 00:00:00 2001 From: Turadg Aleahmad Date: Wed, 27 Sep 2023 08:26:14 -0700 Subject: [PATCH] feat: checked cast with TypedMatcher --- packages/internal/src/index.js | 1 + packages/internal/src/typeCheck.js | 19 ++++++++++++++++ packages/internal/src/types.d.ts | 16 ++++++++++++++ packages/internal/test/test-typeCheck.js | 28 ++++++++++++++++++++++++ 4 files changed, 64 insertions(+) create mode 100644 packages/internal/src/typeCheck.js create mode 100644 packages/internal/test/test-typeCheck.js diff --git a/packages/internal/src/index.js b/packages/internal/src/index.js index c13a3a4b456..5aea1bb89fc 100644 --- a/packages/internal/src/index.js +++ b/packages/internal/src/index.js @@ -6,4 +6,5 @@ export * from './config.js'; export * from './debug.js'; export * from './utils.js'; export * from './method-tools.js'; +export * from './typeCheck.js'; export * from './typeGuards.js'; diff --git a/packages/internal/src/typeCheck.js b/packages/internal/src/typeCheck.js new file mode 100644 index 00000000000..cca9824467e --- /dev/null +++ b/packages/internal/src/typeCheck.js @@ -0,0 +1,19 @@ +// @ts-check +import { mustMatch as typelessMustMatch } from '@endo/patterns'; + +/** @type {import('./types.js').MustMatch} */ +export const mustMatch = typelessMustMatch; + +/** + * @template {import('./types.js').TypedMatcher} M + * @param {unknown} specimen + * @param {M} patt + * @returns {import('./types.js').MatcherType} + */ +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; +}; diff --git a/packages/internal/src/types.d.ts b/packages/internal/src/types.d.ts index 45b3dbf4dc3..60f666c80cb 100644 --- a/packages/internal/src/types.d.ts +++ b/packages/internal/src/types.d.ts @@ -1,4 +1,6 @@ /* eslint-disable max-classes-per-file */ +import type { Matcher } from '@endo/patterns'; + export declare class Callback any> { private iface: I; @@ -18,3 +20,17 @@ export declare class SyncCallback< public isSync: true; } + +declare const typeTag: unique symbol; +export declare type TypedMatcher = Matcher & { + readonly [typeTag]: T; +}; +export declare type MatcherType = M extends TypedMatcher + ? T + : unknown; + +export declare type MustMatch = ( + specimen: unknown, + matcher: M, + label?: string, +) => asserts specimen is MatcherType; diff --git a/packages/internal/test/test-typeCheck.js b/packages/internal/test/test-typeCheck.js new file mode 100644 index 00000000000..96e2f5e9ea6 --- /dev/null +++ b/packages/internal/test/test-typeCheck.js @@ -0,0 +1,28 @@ +// @ts-check +import test from 'ava'; + +import { M } from '@endo/patterns'; +import { cast, mustMatch } from '../src/typeCheck.js'; + +const Mstring = /** @type {import('../src/types.js').TypedMatcher} */ ( + M.string() +); + +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(); +});