Typescript signatures for Meteor Methods

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.

1 Like

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.

3 Likes

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.

2 Likes

Created an issue for it here: https://github.com/meteor/meteor-feature-requests/issues/415

1 Like

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 :wink:

Hope this helps!

1 Like

Nice, have upvoted. :+1:

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}) {
    ...
}
1 Like

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) => {
	...
});
4 Likes

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

image

/** 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…

1 Like

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:

  1. Created an wrapper for replace Meteor.method, it returns the definitions, allowing for export it’s types
  2. Creates a single .d.ts file on server to export all Methods types definitions
  3. 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.

2 Likes

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: {...}, ...]
2 Likes

Great work, @6uimorais. I’ll give it a try. Thanks for sharing your solution

1 Like

For future reference: Zodern:relay - Typesafe Meteor methods and publications looks like a great solution.