Skip to content

Commit c72d678

Browse files
authored
Merge pull request #62 from aoede3/feat/type-augmentation
feat: type augmentation
2 parents 1ff4a3d + ec902d0 commit c72d678

File tree

5 files changed

+153
-45
lines changed

5 files changed

+153
-45
lines changed

src/Config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export type TaujsConfig = CoreTaujsConfig & {
3636

3737
export { callServiceMethod, defineService, defineServiceRegistry, withDeadline } from './core/services/DataServices';
3838

39-
export type { RegistryCaller, ServiceContext } from './core/services/DataServices';
39+
export type { JsonObject, JsonPrimitive, JsonValue, RegistryCaller, ServiceContext, TypedServiceContext } from './core/services/DataServices';
4040

4141
export type RouteContext = CoreRouteContext<TaujsConfig>;
4242
export type RouteData<C extends TaujsConfig = TaujsConfig, P extends string = string> = CoreRouteData<C, P>;

src/core/config/Config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type { CoreTaujsConfig } from './types';
1212

1313
export { callServiceMethod, defineService, defineServiceRegistry, withDeadline } from '../services/DataServices';
1414

15-
export type { RegistryCaller, ServiceContext } from '../services/DataServices';
15+
export type { JsonObject, JsonPrimitive, JsonValue, RegistryCaller, ServiceContext, TypedServiceContext } from '../services/DataServices';
1616

1717
export { AppError } from '../errors/AppError';
1818

src/core/routes/DataRoutes.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,10 +157,15 @@ export const fetchInitialData = async <Params extends PathToRegExpParams, R exte
157157
headers: ctx.headers ?? {},
158158
} as const;
159159

160-
ensureServiceCaller(serviceRegistry, ctxForData);
160+
ensureServiceCaller(serviceRegistry, ctxForData as ServiceContext & Partial<{ call: typeof ctxForData.call }>);
161161

162162
try {
163-
const result = await dataHandler(params, ctxForData);
163+
const result = await dataHandler(
164+
params,
165+
(ctxForData as unknown) as RequestServiceContext<L> & {
166+
call: NonNullable<RequestServiceContext<L>['call']>;
167+
} & { [key: string]: unknown },
168+
);
164169

165170
if (isServiceDescriptor(result)) {
166171
const { serviceName, serviceMethod, args } = result;

src/core/services/DataServices.ts

Lines changed: 61 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,8 @@ import { resolveLogs } from '../logging/resolve';
44
import type { Logs } from '../logging/types';
55
import { now } from '../telemetry/Telemetry';
66

7-
export type RegistryCaller<R extends ServiceRegistry = ServiceRegistry> = (
8-
serviceName: keyof R & string,
9-
methodName: string,
10-
args?: JsonObject,
11-
) => Promise<JsonObject>;
12-
13-
export function createCaller<R extends ServiceRegistry>(registry: R, ctx: ServiceContext): RegistryCaller<R> {
14-
return (serviceName, methodName, args) => callServiceMethod(registry, serviceName, methodName, (args ?? {}) as JsonObject, ctx);
15-
}
16-
17-
// ctx has a bound `call` function (returns the same object reference)?
18-
export function ensureServiceCaller<R extends ServiceRegistry>(
19-
registry: R,
20-
ctx: ServiceContext & Partial<{ call: RegistryCaller<R> }>,
21-
): asserts ctx is ServiceContext & { call: RegistryCaller<R> } {
22-
if (!ctx.call) (ctx as any).call = createCaller(registry, ctx);
23-
}
24-
257
// runtime checks instead happens at the boundary
26-
type JsonPrimitive = string | number | boolean | null;
8+
export type JsonPrimitive = string | number | boolean | null;
279
export type JsonValue = JsonPrimitive | JsonValue[] | { [k: string]: JsonValue };
2810
export type JsonObject = { [k: string]: JsonValue };
2911

@@ -35,15 +17,56 @@ const runSchema = <T>(schema: NarrowSchema<T> | undefined, input: unknown): T =>
3517
return typeof (schema as any).parse === 'function' ? (schema as any).parse(input) : (schema as (u: unknown) => T)(input);
3618
};
3719

38-
export type ServiceContext = {
20+
type BaseServiceContext = {
3921
signal?: AbortSignal; // request/client abort passed in request
4022
deadlineMs?: number; // available to userland; not enforced here
4123
traceId?: string;
4224
logger?: Logs;
4325
user?: { id: string; roles: string[] } | null;
44-
call?: (service: string, method: string, args?: JsonObject) => Promise<JsonObject>;
4526
};
4627

28+
type UntypedRegistryCaller = (serviceName: string, methodName: string, args?: JsonObject) => Promise<JsonObject>;
29+
type RuntimeServiceContext = BaseServiceContext & { call?: UntypedRegistryCaller };
30+
31+
// Augment with app-specific fields only; use TypedServiceContext<typeof serviceRegistry>
32+
// when you want a registry-aware ctx.call type.
33+
export interface ServiceContext extends BaseServiceContext {}
34+
35+
export type ServiceMethod<P extends JsonObject = JsonObject, R extends JsonObject = JsonObject, Ctx extends BaseServiceContext = TypedServiceContext> = (
36+
params: P,
37+
ctx: Ctx,
38+
) => Promise<R>;
39+
type RuntimeServiceMethod<P extends JsonObject = JsonObject, R extends JsonObject = JsonObject> = (params: P, ctx: RuntimeServiceContext) => Promise<R>;
40+
41+
export type ServiceDefinition = Readonly<Record<string, RuntimeServiceMethod<any, JsonObject>>>;
42+
export type ServiceRegistry = Readonly<Record<string, ServiceDefinition>>;
43+
44+
type ServiceMethodParams<M> = M extends (params: infer P, ctx: any) => Promise<any> ? P : never;
45+
type ServiceMethodResult<M> = Awaited<M extends (...args: any[]) => Promise<infer R> ? R : never>;
46+
type RegistryCallerArgs<R extends ServiceRegistry, S extends keyof R & string, M extends keyof R[S] & string> = undefined extends ServiceMethodParams<R[S][M]>
47+
? [serviceName: S, methodName: M, args?: ServiceMethodParams<R[S][M]>]
48+
: [serviceName: S, methodName: M, args: ServiceMethodParams<R[S][M]>];
49+
50+
export type RegistryCaller<R extends ServiceRegistry = ServiceRegistry> = <S extends keyof R & string, M extends keyof R[S] & string>(
51+
...args: RegistryCallerArgs<R, S, M>
52+
) => Promise<ServiceMethodResult<R[S][M]>>;
53+
54+
// Binds ctx.call to a concrete registry without creating a parallel contract type.
55+
export type TypedServiceContext<R extends ServiceRegistry = ServiceRegistry> = ServiceContext & { call?: RegistryCaller<R> };
56+
57+
export function createCaller<R extends ServiceRegistry>(registry: R, ctx: BaseServiceContext): RegistryCaller<R> {
58+
return (((serviceName: string, methodName: string, args?: JsonObject) =>
59+
callServiceMethod(registry, serviceName, methodName, (args ?? {}) as JsonObject, ctx)) as unknown) as RegistryCaller<R>;
60+
}
61+
62+
// ctx has a bound `call` function (returns the same object reference)?
63+
export function ensureServiceCaller<R extends ServiceRegistry>(
64+
registry: R,
65+
ctx: BaseServiceContext & Partial<{ call: RegistryCaller<R> }>,
66+
): asserts ctx is BaseServiceContext & { call: RegistryCaller<R> } {
67+
if (!ctx.call) (ctx as any).call = createCaller(registry, ctx);
68+
}
69+
4770
// Helper for userland: combine a parent AbortSignal with a per-call timeout
4871
export function withDeadline(signal: AbortSignal | undefined, ms?: number): AbortSignal | undefined {
4972
if (!ms) return signal;
@@ -63,45 +86,42 @@ export function withDeadline(signal: AbortSignal | undefined, ms?: number): Abor
6386
return ctrl.signal;
6487
}
6588

66-
export type ServiceMethod<P, R extends JsonObject = JsonObject> = (params: P, ctx: ServiceContext) => Promise<R>;
67-
export type ServiceDefinition = Readonly<Record<string, ServiceMethod<any, JsonObject>>>;
68-
export type ServiceRegistry = Readonly<Record<string, ServiceDefinition>>;
69-
7089
export type ServiceDescriptor = {
7190
serviceName: string;
7291
serviceMethod: string;
7392
args?: JsonObject;
7493
};
7594

95+
type ServiceSpecEntry =
96+
| ServiceMethod<any, JsonObject>
97+
| { handler: ServiceMethod<any, JsonObject>; params?: NarrowSchema<any>; result?: NarrowSchema<any> };
98+
type ServiceSpec = Record<string, ServiceSpecEntry>;
99+
type ExtractServiceMethod<T> = T extends { handler: infer H } ? H : T;
100+
type NormalizeServiceMethod<M> = M extends (params: infer P extends JsonObject, ctx: any) => Promise<infer R extends JsonObject> ? RuntimeServiceMethod<P, R> : never;
101+
type NormalizedServiceSpec<T extends ServiceSpec> = {
102+
[K in keyof T]: NormalizeServiceMethod<ExtractServiceMethod<T[K]>>;
103+
};
104+
76105
export function defineService<
77-
T extends Record<
78-
string,
79-
ServiceMethod<any, JsonObject> | { handler: ServiceMethod<any, JsonObject>; params?: NarrowSchema<any>; result?: NarrowSchema<any> }
80-
>,
106+
T extends ServiceSpec,
81107
>(spec: T) {
82-
const out: Record<string, ServiceMethod<any, JsonObject>> = {};
108+
const out: Record<string, RuntimeServiceMethod<any, JsonObject>> = {};
83109

84110
for (const [name, v] of Object.entries(spec)) {
85111
if (typeof v === 'function') {
86-
out[name] = v; // already a handler
112+
out[name] = v as RuntimeServiceMethod<any, JsonObject>;
87113
} else {
88114
const { handler, params: paramsSchema, result: resultSchema } = v;
89115
out[name] = async (params, ctx) => {
90116
const p = runSchema(paramsSchema, params);
91-
const r = await handler(p, ctx);
117+
const r = await handler(p, ctx as ServiceContext);
92118

93119
return runSchema(resultSchema, r);
94120
};
95121
}
96122
}
97123

98-
return Object.freeze(out) as {
99-
[K in keyof T]: T[K] extends ServiceMethod<infer P, infer R>
100-
? ServiceMethod<P, R>
101-
: T[K] extends { handler: ServiceMethod<infer P, infer R> }
102-
? ServiceMethod<P, R>
103-
: never;
104-
};
124+
return Object.freeze(out) as NormalizedServiceSpec<T>;
105125
}
106126

107127
export const defineServiceRegistry = <R extends ServiceRegistry>(registry: R): R =>
@@ -115,7 +135,7 @@ export async function callServiceMethod(
115135
serviceName: string,
116136
methodName: string,
117137
params: JsonObject | undefined,
118-
ctx: ServiceContext,
138+
ctx: BaseServiceContext,
119139
): Promise<JsonObject> {
120140
if (ctx.signal?.aborted) throw AppError.timeout('Request canceled');
121141

@@ -138,7 +158,7 @@ export async function callServiceMethod(
138158

139159
try {
140160
// No automatic deadlines here; handlers can use ctx.signal or withDeadline(ctx.signal, ms)
141-
const result = await method(params ?? {}, ctx);
161+
const result = await method(params ?? {}, ctx as RuntimeServiceContext);
142162

143163
if (typeof result !== 'object' || result === null) {
144164
throw AppError.internal(`Non-object result from ${serviceName}.${methodName}`);
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { defineService, defineServiceRegistry } from '../../../Config';
2+
3+
import type { ServiceContext, TypedServiceContext } from '../../../Config';
4+
5+
declare module '../../../Config' {
6+
interface ServiceContext {
7+
tenantId?: string;
8+
}
9+
}
10+
11+
const marketService = defineService({
12+
getItem: async (params: { id: string }) => ({
13+
item: {
14+
id: params.id,
15+
title: 'Sample item',
16+
},
17+
}),
18+
});
19+
20+
type AuthDependencies = {
21+
market: typeof marketService;
22+
};
23+
24+
const authService = defineService({
25+
login: async (params: { email: string; password: string }, ctx: TypedServiceContext<AuthDependencies>) => {
26+
const tenantId: string | undefined = ctx.tenantId;
27+
void tenantId;
28+
29+
if (!ctx.call) {
30+
throw new Error('call unavailable');
31+
}
32+
33+
const item = await ctx.call('market', 'getItem', { id: params.email });
34+
35+
// @ts-expect-error method names should be narrowed by service
36+
await ctx.call('market', 'login', { id: params.email });
37+
38+
// @ts-expect-error args should be checked per method
39+
await ctx.call('market', 'getItem', { slug: params.email });
40+
41+
return {
42+
user: {
43+
id: item.item.id,
44+
role: params.password.length > 0 ? 'member' : 'guest',
45+
},
46+
};
47+
},
48+
});
49+
50+
const serviceRegistry = defineServiceRegistry({
51+
auth: authService,
52+
market: marketService,
53+
});
54+
55+
declare const serviceCtx: ServiceContext;
56+
declare const typedCtx: TypedServiceContext<typeof serviceRegistry>;
57+
58+
if (serviceCtx.tenantId) {
59+
const tenantId: string = serviceCtx.tenantId;
60+
void tenantId;
61+
}
62+
63+
if (typedCtx.call) {
64+
const marketResult: Promise<{ item: { id: string; title: string } }> = typedCtx.call('market', 'getItem', { id: 'sku_123' });
65+
const loginResult: Promise<{ user: { id: string; role: string } }> = typedCtx.call('auth', 'login', {
66+
email: 'user@example.com',
67+
password: 'secret',
68+
});
69+
70+
void marketResult;
71+
void loginResult;
72+
73+
// @ts-expect-error service names should be narrowed
74+
typedCtx.call('missing', 'getItem', { id: 'sku_123' });
75+
76+
// @ts-expect-error method names should be narrowed by service
77+
typedCtx.call('market', 'login', { id: 'sku_123' });
78+
79+
// @ts-expect-error args should be checked per method
80+
typedCtx.call('auth', 'login', { email: 'user@example.com' });
81+
}
82+
83+
void serviceRegistry;

0 commit comments

Comments
 (0)