Is there an (easy) way to add Typescript definitions for your Meteor Methods? I’ve read through this: https://itnext.io/strong-typed-meteor-methods-8acafb36c494 but it’s waay to complicated. I am ok with just manually creating a methods.d.ts
file somewhere that defines the parameters and return type of each method.
Interesting article.
My not so elegant way of doing this (but far simpler IMO ) is to define the required param types when writing the method:
export type MethodNameParams = {
foo: string;
bar: number;
{
export type ReturnType = boolean // Or whatever you are returning, if anything
...
run(methodData: MethodNameParams): ReturnType {
// Do stuff and return something of type ReturnType
}
And the on the client:
import { methodName, MethodNameParams, ReturnType } from ./wherever.ts
...
const handleEvent = (e) => {
e.preventDefault() // If needed
const methodData: MethodNameParams = {
foo: 'String',
bar: 4,
}
methodName.call(methodData, (err: Meteor.Error, res: ReturnType)=>{
// Handle method response
})
}
Much dirtier but it gets me the type-checking I’m after.
Yeah, I did something similar now; just created a facade function in Typescript:
type Props = {
prj: string
vrs: string
}
export type Result = {
project: Project
versionId: string
}
export default function resolveVersion(
{ prj, vrs }: Props,
callback: ( e: Meteor.Error | null, r: Result ) => void
) {
Meteor.call( 'resolve.version', { prj, vrs }, callback )
}
Works, but feels kind of bolted-on. I feel Meteor needs a better story for this, it could be interesting to make a Meteor Methods 2.0 with validation, Typescript and promises built in.
Agreed, this is something I miss a lot.
I do something similar but using generics it gets a little bit more standard.
I defined a “Method” class that has a name and a “run” method with generic types for args and result (I always use one parameter only to make things simpler).
export class Method<T, R> {
constructor(
public name: string,
public schema: SimpleSchema | null,
public run: (args: T) => R
) {}
}
(and an optional simpl-schema object)
On the server I create methods with their own types T & R, ex:
const getRider = new Method('getRider', null, (_id: Id) => Riders.findOne({ _id }));
// Collection is typed, in the end getRider has T == Id and R == Rider
// Notice the types are inferred which is very convenient
Then the method can be declared on the server (I use ValidatedMethod for that) and for the client I use a generic method, something like:
static toCallable<T, R>(method: Method<T, R>): (args: T, callback: (e: Meteor.Error | null, r: R) => void) => void {
return (args: T, callback: (e: Meteor.Error | null, r: R) => void) => Meteor.call(method.name, args, callback);
}
so in the client it would be something like:
toCallable(getRider)(myId, myCallback);
Sorry, I have tried to simplify it because the real code is using meteor-rxjs and is a little bit more complicated. Please forgive typos
Hope this helps!
Nice, have upvoted.
Have a side project planned for next month, going to give NextJS a shot. Keen to try a couple of the things you mentioned there.
Edit: I’ve improved my technique since this post, see my updated technique below.
Expand this for the original technique
I’ve just developed a bit of a pattern for strongly typing meteor methods, which works on legacy projects without validated-method
or any other dependencies, and also works where the paramaters are not lumped into a single object. It’s able to be applied to existing projects without refactoring tons of code.
I define some generic stuff in my @types/meteor.d.ts
file:
type Callback<T extends Method> = (e: null | Meteor.Error, r: T['R'] | null) => void;
interface Method {
N: string; // method name
A: any[]; // array of arguments
R: any; // result
}
declare module Meteor {
function call<T extends Method = never>(name: T['N'], ...args: [...args: T['A'], callback?: Callback<T>]): T['R'];
}
For simplicity, I add the method signatures into some global d.ts
files in the project @types
folder, organized by collection. Then, for example, in the users.d.ts
file:
declare namespace U {
interface UpdateKiosk extends Method {
N: 'server.users.updateKiosk';
A: [data: { kioskId: string, notify: string | null, name: string, password: string }];
R: void;
}
interface Delete extends Method {
N: 'server.users.delete';
A: [id: string, options?: {retainLogs?: boolean}];
R: 'User deleted' | 'User removed';
}
...
}
Then, to check that the method definition is taking the correct parameters and has the correct return type:
/// /server/methods/users.ts
Meteor.methods({
'server.users.updateKiosk': function (...[{kioskId, name, password, notify}]: U.UpdateKiosk['A']): U.UpdateKiosk['R'] {
// actual checking of input using check()...
...
'server.users.delete': function (...[id, {retainLogs} = {retainLogs: false}]: U.Delete['A']): U.Delete['R'] { ...
Then, when calling a method, the input parameters and return type are automatically inferred without any extra type annotations:
The only caveat is where some method parameters are optional and are not included in the method call, which causes the type checking to fail. In this case you can enforce correct type checking like this:
If you want, you can simplify the method definition with a helper function:
/// /server/methods/users.ts
function addTypedMethod<T extends Method = never>(name: T['N'], fn: (this: Meteor.MethodThisType, ...args: T['A']) => T['R']) {
Meteor.methods({[name]: fn});
}
addTypedMethod<U.Delete>('server.users.delete', function(id, {retainLogs} = {retainLogs: false}) {
...
}
I’ve improved my technique for adding typescript definitions to Meteor methods. This technique is much closer to the “Advanced method boilerplate” in the Guide, but has typings and allows for server-only methods (I have some methods which do not run on the client because I don’t want optimistic UI, and to reduce client bundle size).
This technique requires a helper class:
// /imports/ValidateMethod.ts
export default class TypedMethod<A extends any[], R> {
private methodFn: any;
// _callback, _args and _result are only defined so we can get the types externally if we want to define variables with the correct types
// alternatively we can use the helper functions below
public _callback: (_error: Meteor.Error | null, _result: R | null) => void;
public _args: A = [] as any;
public _result: R = null as any;
constructor(private name: string, methodFn?: (this: Meteor.MethodThisType, ...args: A) => R) {
if (methodFn) {
this.setHandler(methodFn);
}
this._callback = (_e, _r) => {};
}
setHandler(methodFn: (this: Meteor.MethodThisType, ...args: A) => R) {
if (this.methodFn) {
throw new Meteor.Error(`Typed Method ${this.name} has already been instantiated with a handler function.`);
}
this.methodFn = methodFn;
Meteor.methods({[this.name]: this.methodFn});
}
call(...argsAndCallback: [...args: A, callback?: (error: Meteor.Error | null, result: R | null) => void]): R {
return Meteor.call(this.name, ...argsAndCallback);
}
// helper function to set the args with a callback if some of the args are optional
withArgs(...args: A) {
return {
call: (callback?: (error: Meteor.Error | null, result: R | null) => void): R => {
return this.call(...args, callback);
}
}
}
// helper function to define a shared callback with the correct result type
withCallback(callback: (error: Meteor.Error | null, result: R | null) => void) {
return {
call: (...args: A): R => {
return this.call(...args, callback);
}
}
}
// helper function to create a callback function with the correct result type
createCallback(callback: (error: Meteor.Error | null, result: R | null) => void) {
return callback;
}
// helper function to create a value with the correct type for the return result
createResult(value: R) {
return value;
}
}
Then to define a client/server method and also a server only method:
// /imports/methods/users.ts
import TypedMethod from '../TypedMethod';
// paramater lists for server only methods need to be defined in advance if you want to give each parameter a name
type AddArgs = [email: string, name: string, teamId: string, roles: Meteor.UserRole | Meteor.UserRole[]];
type DeleteArgs = [id: string, options?: {retainLogs?: boolean}];
export const userMethods = {
server: {
// for server-only methods we only define the method name and argument types
add: new TypedMethod<AddArgs, string>('server.users.add'),
delete: new TypedMethod<DeleteArgs, 'User deleted' | 'User removed'>('server.users.delete'),
...
},
// for client/server methods we can define the handler here too, just like Meteor.methods()
rename: new TypedMethod('users.renameUser', function (id: string, newName: string) {
...
},
For server-only methods, the method handler has to be added server-side only:
// /server/methods/users.ts
import { userMethods } from '/imports/methods/users';
// the types of the parameters below are automatically determined ;-)
userMethods.server.add.setHandler(function (email, name, teamId, roles) {
...
})
Then to call any method:
import { userMethods } from '/imports/methods/users';
// the types of all input paramters, and callback parameters are automatically determined:
userMethods.server.add.call(this.user?.email || '', name, this.teamId, 'teams', (err, newId) => {
...
});
If you want to define a shared callback with the correct return type you can do this:
const sharedCallback: typeof blogMethods.server.getTitles._callback = (_, res) => {
or this:
const sharedCallback = blogMethods.server.getTitles.createCallback((_, res) => {
or this:
const getBlogTitlesWithCallback = blogMethods.server.getTitles.withCallback((_, res) => {
...
});
getBlogTitlesWithCallback.call(minDate, maxDate);
If any of the method parameters are optional, then where they are not included and you need the callback you’ll need to do this:
userMethods.setStatus.withArgs(userId, status /* missing params! */).call((error, result) => {
...
});
That’s nice, I like that!
Why not use some custom generic method wrapper and overload for type hinting? I use such solution in my project and it work rather good for autocompletion in Intellij and VSCode
/** helpers/meteor/methods.ts */
export enum Method {
Ensure = 'invoice.ensure',
Transition = 'invoice.transition',
}
export interface EnsureArgs extends MethodArgs {
pos?: objectIdAsString;
orderTypes?: Invoice.Type[];
paymentMethods?: Payment.Method.Model[];
}
export interface TransitionArgs extends MethodArgs {
_id: objectIdLike;
transitionName?: string;
transitionData?: TransitionData;
}
/** helpers/meteor.ts */
export function call(methodName: Invoice.Method.Ensure, options: Invoice.EnsureArgs): Promise<void>
export function call(methodName: Invoice.Method.Transition, options: Invoice.TransitionArgs): Promise<void>
export function call<T>(methodName: string, options: MethodArgs): Promise<T> {
return new Promise((resolve, reject) => {
Meteor.call(methodName, options, (error: Error, result: T) => {
if (error) {
return reject(error)
}
resolve(result)
})
})
}
And using the same enum for method definition on server-side.
The only drawback is that call to method navigation gets broken in Intellij when using non-string-literals method names… However, reliability introduced by type checking seems to be a better option…
Additionally it can be augmented from various other moduels by using module augmentations:
// imports/plugins/integrations/tfa/methods.ts
import { MethodArgs } from '/imports/types/methods'
import { CredentialBearer } from '/imports/types/user'
export enum Method {
Authorize = 'integration.tfa.authorize',
}
export interface AuthorizeArgs extends MethodArgs {
token?: string;
}
declare module '/imports/utils/helpers/meteor' {
// @ts-ignore
export function call(methodName: Method.Authorize, options: AuthorizeArgs): Promise<CredentialBearer>
}
That’s kind of what I was trying to do in my first method, but I was trying to create a generic overload so I didn’t have to define a new overload for each method (I have lots!). However, the generic overload didn’t work in my implementation because you could mix & match method names and arguments.
My new method doesn’t require any overloads to be defined, and for client/server method it doesn’t even need the method arguments or return type to be defined in advance - they are automatically inferred from the new TypedMethod()
call, so minimal extra boilerplate.
We’ve been using a relatively simple way to solve typing on client and server without having to go to far from our usual routines or writing unecessary code, for this we:
- Created an wrapper for replace Meteor.method, it returns the definitions, allowing for export it’s types
- Creates a single .d.ts file on server to export all Methods types definitions
- Created a helper function on the client to make Meteor Calls in a promise like typed way, linked it to the types imported from the server.
The only adicional work I have to do besides my normal workflow is add every new method.ts file to my /server/routes.d.ts so it can be seen on the client.
I’ve detailed it on a notion page, but I’m editing this message because the content is now on the messege below this one.
This is the content of the notion page:
Meteor.method Wrapper
/server/util/meteorMethod.ts
import { Meteor } from "meteor/meteor";
type MeteorMethods = { [key: string]: (this: Meteor.MethodThisType, ...args: any[]) => any };
export const meteorMethods = <T extends MeteorMethods>(methods: T) => {
Meteor.methods(methods);
return methods;
}
meteorMethod Wrapper Usage (sync & async)
/server/users/methods.ts
const methods = meteorMethods({
async createUser: (user: { name: string, email: string }) => {
// ...
const userId = await save(user);
return userId;
},
deleteUser: (userId: string) => {
// ...
return deleteUser(userId);
},
});
export type Methods = typeof methods;
Exporting Methods Type
/server/routes.d.ts
import type { Methods as userMethods } from './users/methods.ts';
import type { Methods as filesMethods } from './files/methods';
export type Methods =
userMethods
& filesMethods;
// & othersMethods
Wrapper for Meteor.calls (Promise)
/client/util/meteorCall.ts
import { Meteor } from 'meteor/meteor';
import { Methods } from '/server/routes';
export type MethodsKeys = keyof Methods;
export type Params<T extends MethodsKeys> = Paramete<Methods[T]>
export type Result<T extends MethodsKeys> = ReturnType<Methods[T]>
export type Param<T extends MethodsKeys, I extends number = 0> = Parameters<Methods[T]>[I];
export const meteorCall = <T extends MethodsKeys>(callName: T, ...parameters: Params<T>)
: Promise<Awaited<Result<T>>> =>
new Promise((resolve, reject) => {
Meteor.call(callName, ...parameters, (
err: Meteor.Error,
result: Result<T>,
) => err ? reject(err as Meteor.Error) : resolve(result));
});
meteorCall Wrapper Usage
const userId = await meteorCall('createUser', {
name: 'Gui',
email: 'gui@linte.com',
});
const userId = await meteorCall(
'deleteUser',
'useruniqueidxyz'
});
This has autocomplete and type checking on the client. Every change on each used method on the server reflects here.
It’s possible get type Params and Returns from server Methods
const params: Param<'createUser'> = {
name: 'Gui',
email: 'gui@linte.com',
};
const userId = await meteorCall(
'createUser',
params,
);
// Param<'methodname', 2> accepts a second arg with the index of the parameter desired
// Return<'methodname'> type also works as expected
// Params<'methodname'> returns an array with all parameters [p1: {...}, p2: {...}, ...]
For future reference: Zodern:relay - Typesafe Meteor methods and publications looks like a great solution.