From 027e5fe9a4b4667ad364243212b262a66de125e2 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:09:13 +0800 Subject: [PATCH] feat(typert): carry Remote absence without a second result envelope Absence crosses the wire as a missing field: an omitted argument and a void or undefined result both arrive as an absent JSON member, and the wide RPC result slot accepts a success response without a value. Parameters declared optional stay optional in the generated consumer declaration, so a business signature is never widened to `T | undefined` to suit the wire. The weak SRC descriptor reads parameter names from a JavaScript signature and cannot see optionality, so a source-launched Host accepts an absent field and the strict LIB pass owns rejecting a genuinely missing required parameter. --- packages/api/gateway/src/index.ts | 44 +++++++++++- packages/api/gateway/tests/gateway.spec.ts | 45 +++++++++++- packages/host/apiproxy/src/api/rpc.schema.ts | 8 ++- .../host/apiproxy/tests/rpc-schemas.spec.ts | 68 +++---------------- packages/typert/generator/src/analyzer.ts | 54 ++++++++++++--- packages/typert/generator/src/model.ts | 4 ++ packages/typert/registry/src/service.ts | 3 + 7 files changed, 149 insertions(+), 77 deletions(-) diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index ee4b063622..9b7ba6f2b1 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -70,6 +70,18 @@ export class TypertGatewayError extends Error { } } +/** Business invocation lost its carrier cancellation race. */ +class RemoteInvocationCancelled extends Error { + /** + * @param endpoint - canonical Remote endpoint. + * @param cause - business rejection observed after carrier cancellation. + */ + constructor(endpoint: string, cause: unknown) { + super(`Remote invocation "${endpoint}" was aborted`, { cause }) + this.name = 'RemoteInvocationCancelled' + } +} + /** * Resolve strict generated definitions or conservative SRC markers against * current Cordis Services and TypeRT providers. @@ -157,7 +169,13 @@ export class TypertGatewayService extends Service implements TypertGateway { ) } - const result = await Reflect.apply(method, receiver, args) as unknown + let result: unknown + try { + result = await Reflect.apply(method, receiver, args) as unknown + } catch (error) { + if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(endpoint, error) + throw error + } return decode(descriptor.result, result, 'result-invalid', endpoint, 'result') } @@ -190,6 +208,9 @@ export class TypertGatewayService extends Service implements TypertGateway { args: payload.args, signal, }) + // A void or explicitly absent business result carries no `value` field; + // JSON has no `undefined`, and the envelope's optional slot is the one + // representation of absence that both args and results already use. return { ok: true, value } } catch (error) { return rpcFailure(error) @@ -439,6 +460,12 @@ export class TypertGatewayService extends Service implements TypertGateway { } function rpcFailure(error: unknown): ConnectionRpcResult { + if (error instanceof RemoteInvocationCancelled) { + return { + ok: false, + error: { code: 'cancelled', message: error.message, details: {} }, + } + } if (error instanceof TypeRTLookupFailure) { return { ok: false, error: error.failure as ConnectionRpcError } } @@ -559,7 +586,15 @@ function assertExactArguments( if (descriptor.invocation.kind === 'context') expected.add(descriptor.invocation.wire) const actual = Reflect.ownKeys(args) const extra = actual.filter(key => typeof key !== 'string' || !expected.has(key)) - const missing = [...expected].filter(key => !Object.hasOwn(args, key)) + // A JSON field may be omitted when the strict descriptor declares absence, + // and always under SRC: a weak descriptor reads parameter names from the + // JavaScript signature and cannot see which are optional, so LIB is where an + // omitted required argument is caught. Lookup ids are never omissible. + const acceptsMissing = new Set(descriptor.parameters + .filter(parameter => parameter.source === 'json' + && (parameter.acceptsUndefined === true || parameter.codec.mode === 'src-json')) + .map(parameter => parameter.wire)) + const missing = [...expected].filter(key => !Object.hasOwn(args, key) && !acceptsMissing.has(key)) if (extra.length === 0 && missing.length === 0) return const clauses: string[] = [] if (missing.length > 0) clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`) @@ -575,7 +610,10 @@ function decode( field: string, ): unknown { try { - if (codec.mode === 'strict') value = codec.schema.parse(value) + if (codec.mode === 'strict') { + value = codec.schema.parse(value) + if (value === undefined) return value + } assertJsonValue(value, new Set()) return value } catch (cause) { diff --git a/packages/api/gateway/tests/gateway.spec.ts b/packages/api/gateway/tests/gateway.spec.ts index 38be5c822d..c6f3a59768 100644 --- a/packages/api/gateway/tests/gateway.spec.ts +++ b/packages/api/gateway/tests/gateway.spec.ts @@ -77,6 +77,12 @@ class GoalService extends Service { return this.nextResult === undefined ? value : this.nextResult } + @Remote + maybe(value: string | null | undefined): string | null | undefined { + this.calls.push('maybe') + return value + } + @Remote fail(request: unknown): never { void request @@ -945,7 +951,7 @@ describe('TypertGatewayService', () => { expect(connection).toMatchObject({ channel: '/api', authority: 'trusted-host' }) registerAgentLookup(ctx, { id: 'agent-1' }) - registerStrict(ctx, [createDescriptor()]) + registerStrict(ctx, [createDescriptor(), maybeDescriptor()]) expect(connection.matches?.('goals/create')).toBe(true) expect(connection.matches?.('goals/passthrough')).toBe(true) expect(connection.matches?.('goals')).toBe(false) @@ -973,6 +979,15 @@ describe('TypertGatewayService', () => { if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded') expect(invalid.error.message).toMatch(/exactly one plain-object args field/) + await expect(handler('goals/maybe', { args: {} }, signal)).resolves.toEqual({ + ok: true, + value: undefined, + }) + await expect(handler('goals/maybe', { args: { value: null } }, signal)).resolves.toEqual({ + ok: true, + value: null, + }) + for (const endpoint of ['goals', '/create', 'goals/', 'goals/create/extra']) { const result = await handler(endpoint, { args: {} }, signal) expect(result).toMatchObject({ ok: false, error: { code: 'internal' } }) @@ -987,7 +1002,11 @@ describe('TypertGatewayService', () => { } service.businessError = 'non-error failure' as unknown as Error - await expect(handler('goals/fail', { args: { request: null } }, signal)).resolves.toEqual({ + await expect(handler( + 'goals/fail', + { args: { request: null } }, + new AbortController().signal, + )).resolves.toEqual({ ok: false, error: { code: 'internal', message: 'non-error failure', details: {} }, }) @@ -1302,6 +1321,28 @@ function strictOnlyDescriptor(): InvocationDescriptor { } } +function maybeDescriptor(): InvocationDescriptor { + const value = strictCodec( + '@fixture/gateway#MaybeValue', + z.union([z.string(), z.null(), z.undefined()]), + ) + return { + id: '@fixture/gateway#goals/maybe', + service: 'goals', + namespace: 'goals', + method: 'maybe', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'value', + wire: 'value', + source: 'json', + acceptsUndefined: true, + codec: value, + }], + result: value, + } +} + async function expectCode( promise: Promise, code: TypertGatewayError['code'], diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index b508e25f93..53f3c34ec2 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -61,7 +61,6 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }), - z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }), z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }), z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }), z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }), @@ -91,6 +90,9 @@ export function rpcResultSchema(value: z.ZodType): z.ZodUnion /** ServerRequest full form (payload stays wide). */ @@ -119,7 +121,7 @@ export const serverRequestSchema = z.object({ export const clientResponseSchema = z.object({ type: z.literal('client-response'), rpcId: rpcIdSchema, - result: rpcResultSchema(z.unknown()), + result: rpcResultSchema(z.unknown().optional()), }) as unknown as z.ZodType /** Wire full-form union (discriminated by type). */ diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 9a8f9ecc55..0e8ab81ec2 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -20,17 +20,10 @@ import { hostListDirectoryRequestSchema, hostListDirectoryValueSchema, } from '../src/api/host.schema.ts' import { - workspaceArchiveSessionRequestSchema, workspaceArchiveSessionValueSchema, workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceDeleteRequestSchema, workspaceDeleteValueSchema, - workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema, - workspaceListRequestSchema, workspaceListValueSchema, workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema, } from '../src/api/workspace.schema.ts' -import { - commandDescriptorSchema, commandExecuteRequestSchema, commandExecuteValueSchema, - commandListRequestSchema, commandListValueSchema, -} from '../src/api/commands.schema.ts' import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' import { agentPresetEntrySchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema, @@ -115,9 +108,14 @@ describe('wire full-form schemas', () => { expect(() => rpcMessageSchema.parse({ type: 'other', rpcId: 'x' })).toThrow() }) - it('rejects a quadrant missing its members', () => { + it('rejects a quadrant missing its members but accepts a valueless success result', () => { expect(() => clientRequestSchema.parse({ type: 'client-request', rpcId: 'r1' })).toThrow() - expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: { ok: true } })).toThrow() + expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1' })).toThrow() + expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: {} })).toThrow() + // A void business result carries no value field; the endpoint's own second + // parse is what requires a value for methods that return data. + expect(serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: { ok: true } }).rpcId) + .toBe('r1') }) }) @@ -346,22 +344,11 @@ describe('workspace domain schemas', () => { createdAt: '2026-07-25T00:00:00.000Z', updatedAt: '2026-07-25T00:00:00.000Z', } - it('validates ids, the view row, and list request/value', () => { + it('validates ids and the view row', () => { expect(workspaceIdSchema.parse('w1')).toBe('w1') expect(() => workspaceIdSchema.parse('')).toThrow() expect(workspaceViewSchema.parse(view).sessionIds).toEqual(['s1']) expect(() => workspaceViewSchema.parse({ ...view, sessionIds: 's1' })).toThrow() - expect(workspaceListRequestSchema.parse({})).toEqual({}) - expect(workspaceListValueSchema.parse({ items: [view], archivedSessionIds: ['s1'] }).items).toHaveLength(1) - expect(() => workspaceListValueSchema.parse({ items: [view] })).toThrow() - }) - - it('archiveSession request/value carry the id and the full updated set', () => { - expect(workspaceArchiveSessionRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') - expect(() => workspaceArchiveSessionRequestSchema.parse({})).toThrow() - expect(workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: ['s1', 's2'] }).archivedSessionIds) - .toEqual(['s1', 's2']) - expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow() }) it('create requires a path', () => { @@ -384,45 +371,6 @@ describe('workspace domain schemas', () => { expect(workspaceDeleteValueSchema.parse({ deleted: true })).toEqual({ deleted: true }) expect(() => workspaceDeleteValueSchema.parse({ deleted: false })).toThrow() }) - - it('insertSessionBefore accepts an anchored and an anchorless move', () => { - expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2') - expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined() - expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow() - expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') - }) -}) - -describe('commands domain schemas', () => { - it('validates the catalog request/value pair', () => { - expect(commandListRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') - // The wire is session-addressed only: a sessionId-less payload fails. - expect(() => commandListRequestSchema.parse({})).toThrow() - expect(commandListValueSchema.parse({ commands: [] }).commands).toEqual([]) - const value = commandListValueSchema.parse({ commands: [ - { name: 'plan', description: 'Toggle plan mode' }, - { name: 'goal', description: 'Set the goal', input: { hint: '' } }, - ] }) - expect(value.commands[1]?.input?.hint).toBe('') - expect(commandDescriptorSchema.parse({ name: 'x', description: 'd' }).input).toBeUndefined() - expect(() => commandDescriptorSchema.parse({ name: '', description: 'd' })).toThrow() - expect(() => commandDescriptorSchema.parse({ name: 'x', description: 'd', input: {} })).toThrow() - }) - - it('validates the execute request/value pair with both matched branches', () => { - expect(commandExecuteRequestSchema.parse({ sessionId: 's1', line: '/plan off' }).line).toBe('/plan off') - // Both members are mandatory: dropping either fails the parse. - expect(() => commandExecuteRequestSchema.parse({ line: '/compact' })).toThrow() - expect(() => commandExecuteRequestSchema.parse({ sessionId: 's1' })).toThrow() - expect(commandExecuteValueSchema.parse({ matched: false })).toEqual({ matched: false }) - // Pure admission: matched plus the optional lifecycle pairing id - // (outcomes ride the logged lifecycle events, never this response). - expect(commandExecuteValueSchema.parse({ matched: true, commandId: 'cmd-1' })) - .toEqual({ matched: true, commandId: 'cmd-1' }) - expect(commandExecuteValueSchema.parse({ matched: true })).toEqual({ matched: true }) - expect(() => commandExecuteValueSchema.parse({ matched: true, commandId: '' })).toThrow() - expect(() => commandExecuteValueSchema.parse({})).toThrow() - }) }) describe('skills domain schemas', () => { diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 358edceb66..c922232fe7 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -983,8 +983,8 @@ class FaceAnalyzer { } if (parameter.dotDotDotToken !== undefined) this.fail(parameter, 'Remote parameters cannot be rest parameters') if (parameter.initializer !== undefined) this.fail(parameter, 'Remote parameters cannot have default values') - if (parameter.questionToken !== undefined) this.fail(parameter, 'Remote parameters cannot be optional') if (parameter.name.text === 'this') this.fail(parameter, 'Remote methods cannot declare an explicit this parameter') + const optional = parameter.questionToken !== undefined const authoredType = this.requiredType(parameter, parameter.type, 'parameter') const cancellationName = parameter.name.text === 'signal' const cancellationType = this.isGlobalAbortSignal(authoredType) @@ -1002,6 +1002,7 @@ class FaceAnalyzer { const lookup = hostSymbol === undefined ? undefined : lookupByHost.get(this.symbolId(hostSymbol)) let modeled: InvocationParameterModel if (lookup !== undefined) { + if (optional) this.fail(parameter, `lookup parameter for ${lookup.key} cannot be optional`) if (parameter.name.text !== lookup.key) { this.fail(parameter, `lookup parameter for ${lookup.key} must also be named ${lookup.key}`) } @@ -1025,10 +1026,13 @@ class FaceAnalyzer { name: parameter.name.text, wire: parameter.name.text, source: 'json', + ...optional ? { optional: true as const } : {}, boundary: this.remoteBoundary( authoredType, `${registration.name}#${binding.namespace}/${exportedMethod}:${parameter.name.text}`, false, + 'undefined', + optional, ), } } @@ -1095,6 +1099,7 @@ class FaceAnalyzer { resultType, `${registration.name}#${binding.namespace}/${exportedMethod}:result`, false, + 'undefined-or-void', ), location: this.location(method.name), } @@ -1336,9 +1341,18 @@ class FaceAnalyzer { authoredType: ts.TypeNode, fallbackTypeSymbol: string, requireNamed: boolean, + topLevelAbsence: 'reject' | 'undefined' | 'undefined-or-void' = 'reject', + optional = false, ): RemoteBoundaryModel { const type = this.convertType(authoredType) - const codecType = this.resolvedRemoteCodecType(authoredType) + const declaredType = this.checker.getTypeFromTypeNode(authoredType) + // An optional parameter's authored node carries no `undefined`; the codec + // still has to accept the omitted wire field the consumer sends. + const resolvedType = optional + ? this.checker.getNullableType(declaredType, ts.TypeFlags.Undefined) + : declaredType + const codecType = this.resolvedRemoteCodecType(authoredType, resolvedType, topLevelAbsence) + const acceptsUndefined = topLevelAbsence !== 'reject' && this.includesRemoteAbsence(resolvedType) const rootSymbol = this.namedWorkspaceType(authoredType) const imports = new Map() const visit = (node: ts.Node): void => { @@ -1365,6 +1379,7 @@ class FaceAnalyzer { return { type, codecType, + acceptsUndefined, typeSymbol: `${imported.specifier}#${imported.name}`, imports: [...imports.values()].sort((left, right) => left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)), @@ -1374,6 +1389,7 @@ class FaceAnalyzer { return { type, codecType, + acceptsUndefined, typeSymbol: fallbackTypeSymbol, imports: [...imports.values()].sort((left, right) => left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)), @@ -1387,9 +1403,18 @@ class FaceAnalyzer { * validated without teaching the compiler-independent emitter TypeScript's * type evaluator. */ - private resolvedRemoteCodecType(authoredType: ts.TypeNode): TypeNodeId { - const resolvedType = this.checker.getTypeFromTypeNode(authoredType) - this.assertRemoteJsonType(resolvedType, authoredType, new Set(), false) + private resolvedRemoteCodecType( + authoredType: ts.TypeNode, + resolvedType: ts.Type, + topLevelAbsence: 'reject' | 'undefined' | 'undefined-or-void', + ): TypeNodeId { + this.assertRemoteJsonType( + resolvedType, + authoredType, + new Set(), + topLevelAbsence !== 'reject', + topLevelAbsence === 'undefined-or-void', + ) const completed = new Map() const active = new Map() const recursiveDeclarations = new Map() @@ -1554,9 +1579,11 @@ class FaceAnalyzer { site: ts.TypeNode, active: Set, allowUndefined: boolean, + allowVoid: boolean, ): void { const flags = type.flags if ((flags & ts.TypeFlags.Undefined) !== 0 && allowUndefined) return + if ((flags & ts.TypeFlags.Void) !== 0 && allowVoid) return if ((flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) { this.fail(site, `Remote boundary contains unconstrained ${this.checker.typeToString(type)} data`) } @@ -1569,13 +1596,15 @@ class FaceAnalyzer { | ts.TypeFlags.Null | ts.TypeFlags.Never)) !== 0) return if (type.isUnion()) { - for (const member of type.types) this.assertRemoteJsonType(member, site, active, allowUndefined) + for (const member of type.types) { + this.assertRemoteJsonType(member, site, active, allowUndefined, allowVoid) + } return } if (type.isIntersection()) { const material = type.types.filter(member => !this.isRemotePhantomConstraint(member)) if (material.length === 0) this.fail(site, 'Remote boundary contains a symbol-only object') - for (const member of material) this.assertRemoteJsonType(member, site, active, false) + for (const member of material) this.assertRemoteJsonType(member, site, active, false, false) return } if ((flags & ts.TypeFlags.TypeParameter) !== 0) { @@ -1606,6 +1635,7 @@ class FaceAnalyzer { site, active, (elementFlags & ts.ElementFlags.Optional) !== 0, + false, ) }) return @@ -1613,7 +1643,7 @@ class FaceAnalyzer { if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) { const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number) if (element === undefined) this.fail(site, 'Remote boundary array has no element type') - this.assertRemoteJsonType(element, site, active, false) + this.assertRemoteJsonType(element, site, active, false, false) return } const properties = this.checker.getPropertiesOfType(type) @@ -1628,19 +1658,25 @@ class FaceAnalyzer { site, active, (property.flags & ts.SymbolFlags.Optional) !== 0, + false, ) } for (const info of this.checker.getIndexInfosOfType(type)) { if ((info.keyType.flags & ts.TypeFlags.ESSymbolLike) !== 0) { this.fail(site, 'Remote boundary contains a symbol index signature') } - this.assertRemoteJsonType(info.type, site, active, false) + this.assertRemoteJsonType(info.type, site, active, false, false) } } finally { active.delete(type) } } + private includesRemoteAbsence(type: ts.Type): boolean { + if ((type.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0) return true + return type.isUnion() && type.types.some(member => this.includesRemoteAbsence(member)) + } + private isRemotePhantomConstraint(type: ts.Type): boolean { if ((type.flags & ts.TypeFlags.Unknown) !== 0) return true if ((type.flags & ts.TypeFlags.Any) !== 0 || (type.flags & ts.TypeFlags.Object) === 0) return false diff --git a/packages/typert/generator/src/model.ts b/packages/typert/generator/src/model.ts index 81bc6a91a1..40eb31dde8 100644 --- a/packages/typert/generator/src/model.ts +++ b/packages/typert/generator/src/model.ts @@ -107,6 +107,8 @@ export interface RemoteBoundaryModel { readonly type: TypeNodeId /** Checker-resolved projection used only to emit the runtime codec. */ readonly codecType: TypeNodeId + /** Whether the authored top-level boundary explicitly accepts `undefined`. */ + readonly acceptsUndefined: boolean readonly typeSymbol: string readonly imports: readonly RemoteTypeImportModel[] } @@ -117,6 +119,8 @@ export interface InvocationParameterModel { readonly wire: string readonly source: 'json' | 'lookup' readonly lookup?: string + /** Authored as an optional parameter, so consumers may omit the wire field. */ + readonly optional?: true readonly boundary: RemoteBoundaryModel } diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index b8bab3a121..06e2161a1e 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -653,6 +653,9 @@ function validateInvocation(descriptor: InvocationDescriptor): void { } wires.add(parameter.wire) if (parameter.source === 'lookup') { + if (parameter.acceptsUndefined !== undefined) { + throw new Error(`typert: invocation "${descriptor.id}" lookup parameter "${parameter.name}" cannot accept undefined`) + } if (parameter.lookup === undefined) { throw new Error(`typert: invocation "${descriptor.id}" lookup parameter "${parameter.name}" has no lookup key`) }