From 67c04d247f2611cf515dacad87e74e23e788a975 Mon Sep 17 00:00:00 2001 From: replitom Date: Sat, 5 Sep 2026 06:11:54 +0000 Subject: [PATCH 1/2] Export nameable River procedure types Assisted-by: Replit --- .../fixtures/public-declaration-consumer.ts | 62 +++++++++++++++++++ __tests__/public-declaration-types.test.ts | 58 +++++++++++++++++ router/index.ts | 6 ++ router/procedures.ts | 50 ++++++++++----- router/services.ts | 54 ++++++++++------ 5 files changed, 193 insertions(+), 37 deletions(-) create mode 100644 __tests__/fixtures/public-declaration-consumer.ts create mode 100644 __tests__/public-declaration-types.test.ts diff --git a/__tests__/fixtures/public-declaration-consumer.ts b/__tests__/fixtures/public-declaration-consumer.ts new file mode 100644 index 00000000..18d34167 --- /dev/null +++ b/__tests__/fixtures/public-declaration-consumer.ts @@ -0,0 +1,62 @@ +import { type Static, type TNever, Type } from 'typebox'; +import { + createServiceSchema, + type InstantiatedServiceSchemaMap, + Procedure, + type ProcedureDefinition, + type ProcedureDefinitionMap, + type RPCProcedure, +} from '../../router'; + +const RequestSchema = Type.Object({ value: Type.String() }); +const ResponseSchema = Type.Object({ value: Type.String() }); + +type Equal = (() => T extends Left ? 1 : 2) extends < + T, +>() => T extends Right ? 1 : 2 + ? true + : false; +type Assert = T; + +type EchoProcedure = RPCProcedure< + object, + object, + object, + typeof RequestSchema, + typeof ResponseSchema, + TNever +>; + +export const echo = Procedure.rpc({ + requestInit: RequestSchema, + responseData: ResponseSchema, + async handler({ reqInit }) { + return { ok: true, payload: reqInit }; + }, +}); + +const definition: ProcedureDefinition = echo; +const constructorResult: typeof echo = definition; +void constructorResult; + +export const procedures: ProcedureDefinitionMap<{ + echo: EchoProcedure; +}> = { echo }; + +type EchoRequest = Static<(typeof procedures)['echo']['requestInit']>; +export type ProcedureNamesAreExact = Assert< + Equal +>; +export type RequestPayloadIsPreserved = Assert< + Equal +>; + +const ServiceSchema = createServiceSchema(); +export const EchoServiceSchema = ServiceSchema.define(procedures); +const serviceSchemas = { echo: EchoServiceSchema }; + +export type InstantiatedServices = InstantiatedServiceSchemaMap< + Record, + object, + typeof serviceSchemas +>; diff --git a/__tests__/public-declaration-types.test.ts b/__tests__/public-declaration-types.test.ts new file mode 100644 index 00000000..dbd3672b --- /dev/null +++ b/__tests__/public-declaration-types.test.ts @@ -0,0 +1,58 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { expect, test } from 'vitest'; + +test('public procedure and service-map types support declaration emit', () => { + const rootDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + ); + const config = ts.readConfigFile( + path.join(rootDir, 'tsconfig.json'), + ts.sys.readFile, + ); + expect(config.error).toBeUndefined(); + + const parsed = ts.parseJsonConfigFileContent( + config.config, + ts.sys, + rootDir, + { + declaration: true, + declarationMap: false, + emitDeclarationOnly: true, + noEmit: false, + }, + path.join(rootDir, 'tsconfig.json'), + ); + const fixture = path.join( + rootDir, + '__tests__/fixtures/public-declaration-consumer.ts', + ); + const program = ts.createProgram([fixture], parsed.options); + const declarations = new Map(); + const emit = program.emit(undefined, (fileName, contents) => { + if (fileName.endsWith('.d.ts')) { + declarations.set(path.basename(fileName), contents); + } + }); + const diagnostics = [ + ...ts.getPreEmitDiagnostics(program), + ...emit.diagnostics, + ]; + + expect( + diagnostics.map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'), + ), + ).toEqual([]); + + const consumerDeclaration = declarations.get( + 'public-declaration-consumer.d.ts', + ); + expect(consumerDeclaration).toContain('ProcedureDefinition'); + expect(consumerDeclaration).toContain('ProcedureDefinitionMap'); + expect(consumerDeclaration).toContain('InstantiatedServiceSchemaMap'); + expect(consumerDeclaration).not.toContain('__BRAND_DO_NOT_USE'); +}, 10_000); diff --git a/router/index.ts b/router/index.ts index f748101b..81088043 100644 --- a/router/index.ts +++ b/router/index.ts @@ -8,6 +8,9 @@ export type { ProcResponse, ProcErrors, ProcType, + AnyServiceSchema, + AnyServiceSchemaMap, + InstantiatedServiceSchemaMap, } from './services'; export { createServiceSchema, @@ -24,6 +27,9 @@ export type { ValidProcType, PayloadType, ProcedureMap, + ProcedureDefinition, + ProcedureDefinitionMap, + UnwrapProcedureDefinition, RpcProcedure as RPCProcedure, UploadProcedure, SubscriptionProcedure, diff --git a/router/procedures.ts b/router/procedures.ts index 8db28152..0d2caa22 100644 --- a/router/procedures.ts +++ b/router/procedures.ts @@ -9,15 +9,7 @@ import { ReaderErrorSchema, } from './errors'; -/** - * Brands a type to prevent it from being directly constructed. - */ -export type Branded = T & { readonly __BRAND_DO_NOT_USE: unique symbol }; - -/** - * Unbrands a {@link Branded} type. - */ -export type Unbranded = T extends Branded ? U : never; +type Branded = T & { readonly __BRAND_DO_NOT_USE: unique symbol }; /** * The valid {@link Procedure} types. The `stream` and `upload` types can optionally have a @@ -258,6 +250,20 @@ export type AnyProcedure< ProcedureErrorSchemaType >; +/** + * A procedure created by one of the {@link Procedure} constructors. + */ +export type ProcedureDefinition = Branded; + +/** + * Extracts the procedure represented by a {@link ProcedureDefinition}. + */ +export type UnwrapProcedureDefinition = T extends ProcedureDefinition< + infer Procedure +> + ? Procedure + : never; + // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyRpcProcedure = RpcProcedure; @@ -276,6 +282,16 @@ export type ProcedureMap< ParsedMetadata = object, > = Record>; +/** + * Preserves the exact keys and procedure types of a map while requiring every + * value to come from a {@link Procedure} constructor. + */ +export type ProcedureDefinitionMap< + Procedures extends Record = ProcedureMap, +> = { + [K in keyof Procedures]: ProcedureDefinition; +}; + // typescript is funky so with these upcoming procedure constructors, the overloads // which handle the `init` case _must_ come first, otherwise the `init` property // is not recognized as optional, for some reason @@ -303,7 +319,7 @@ function rpc< ResponseData, TNever >['handler']; -}): Branded< +}): ProcedureDefinition< RpcProcedure< Context, State, @@ -335,7 +351,7 @@ function rpc< ResponseData, ResponseErr >['handler']; -}): Branded< +}): ProcedureDefinition< RpcProcedure< Context, State, @@ -396,7 +412,7 @@ function upload< ResponseData, TNever >['handler']; -}): Branded< +}): ProcedureDefinition< UploadProcedure< Context, State, @@ -432,7 +448,7 @@ function upload< ResponseData, ResponseErr >['handler']; -}): Branded< +}): ProcedureDefinition< UploadProcedure< Context, State, @@ -494,7 +510,7 @@ function subscription< ResponseData, TNever >['handler']; -}): Branded< +}): ProcedureDefinition< SubscriptionProcedure< Context, State, @@ -526,7 +542,7 @@ function subscription< ResponseData, ResponseErr >['handler']; -}): Branded< +}): ProcedureDefinition< SubscriptionProcedure< Context, State, @@ -594,7 +610,7 @@ function stream< ResponseData, TNever >['handler']; -}): Branded< +}): ProcedureDefinition< StreamProcedure< Context, State, @@ -630,7 +646,7 @@ function stream< ResponseData, ResponseErr >['handler']; -}): Branded< +}): ProcedureDefinition< StreamProcedure< Context, State, diff --git a/router/services.ts b/router/services.ts index 9ba223d0..99a43228 100644 --- a/router/services.ts +++ b/router/services.ts @@ -1,8 +1,8 @@ import { Type, type TSchema, type Static } from 'typebox'; import { - Branded, + ProcedureDefinitionMap, ProcedureMap, - Unbranded, + UnwrapProcedureDefinition, AnyProcedure, PayloadType, } from './procedures'; @@ -152,10 +152,8 @@ export type ProcType< * A list of procedures where every procedure is "branded", as-in the procedure * was created via the {@link Procedure} constructors. */ -type BrandedProcedureMap = Record< - string, - Branded> ->; +type CompatibleProcedureDefinitionMap = + ProcedureDefinitionMap>; export type MaybeDisposable> = T & { [Symbol.asyncDispose]?: () => PromiseLike; @@ -440,13 +438,17 @@ export function createServiceSchema< */ static define< State extends object, - Procedures extends BrandedProcedureMap, + Procedures extends CompatibleProcedureDefinitionMap< + Context, + State, + ParsedMetadata + >, >( config: ServiceConfiguration, procedures: Procedures, ): ServiceSchema< State, - { [K in keyof Procedures]: Unbranded } + { [K in keyof Procedures]: UnwrapProcedureDefinition } >; /** * Creates a new {@link ServiceSchema} with the given procedures. @@ -472,22 +474,34 @@ export function createServiceSchema< */ static define< - Procedures extends BrandedProcedureMap, + Procedures extends CompatibleProcedureDefinitionMap< + Context, + object, + ParsedMetadata + >, >( procedures: Procedures, ): ServiceSchema< object, - { [K in keyof Procedures]: Unbranded } + { [K in keyof Procedures]: UnwrapProcedureDefinition } >; // actual implementation static define( configOrProcedures: | ServiceConfiguration - | BrandedProcedureMap, - maybeProcedures?: BrandedProcedureMap, + | CompatibleProcedureDefinitionMap, + maybeProcedures?: CompatibleProcedureDefinitionMap< + Context, + object, + ParsedMetadata + >, ): ServiceSchema { let config: ServiceConfiguration; - let procedures: BrandedProcedureMap; + let procedures: CompatibleProcedureDefinitionMap< + Context, + object, + ParsedMetadata + >; if ( 'initializeState' in configOrProcedures && @@ -501,7 +515,7 @@ export function createServiceSchema< procedures = maybeProcedures; } else { config = { initializeState: () => ({}) }; - procedures = configOrProcedures as BrandedProcedureMap< + procedures = configOrProcedures as CompatibleProcedureDefinitionMap< Context, object, ParsedMetadata @@ -675,9 +689,9 @@ class ServiceScaffold< * * @param procedures - The procedures for this service. */ - procedures>( - procedures: T, - ): T { + procedures< + T extends CompatibleProcedureDefinitionMap, + >(procedures: T): T { return procedures; } @@ -699,9 +713,9 @@ class ServiceScaffold< * }); * ``` */ - finalize>( - procedures: T, - ) { + finalize< + T extends CompatibleProcedureDefinitionMap, + >(procedures: T) { return createServiceSchema().define( this.config, procedures, From 77b1fb61edcc0c1ba426dfe4beeb24b601684b4c Mon Sep 17 00:00:00 2001 From: replitom Date: Sat, 5 Sep 2026 06:14:22 +0000 Subject: [PATCH 2/2] Fix declaration test lint Assisted-by: Replit --- __tests__/fixtures/public-declaration-consumer.ts | 1 + __tests__/public-declaration-types.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/__tests__/fixtures/public-declaration-consumer.ts b/__tests__/fixtures/public-declaration-consumer.ts index 18d34167..466e5537 100644 --- a/__tests__/fixtures/public-declaration-consumer.ts +++ b/__tests__/fixtures/public-declaration-consumer.ts @@ -16,6 +16,7 @@ type Equal = (() => T extends Left ? 1 : 2) extends < >() => T extends Right ? 1 : 2 ? true : false; + type Assert = T; type EchoProcedure = RPCProcedure< diff --git a/__tests__/public-declaration-types.test.ts b/__tests__/public-declaration-types.test.ts index dbd3672b..44fc2037 100644 --- a/__tests__/public-declaration-types.test.ts +++ b/__tests__/public-declaration-types.test.ts @@ -10,7 +10,7 @@ test('public procedure and service-map types support declaration emit', () => { ); const config = ts.readConfigFile( path.join(rootDir, 'tsconfig.json'), - ts.sys.readFile, + (fileName) => ts.sys.readFile(fileName), ); expect(config.error).toBeUndefined();