I am playing around with meteor.js and TypeScript and trying to make meteor methods that are strongly typed.
For this I created a file containing type definitions for my methods like so:
export interface ClientWithSecret {
id: string;
secret: string;
}
export interface MeteorMethod {
name: string;
args: any[];
return: any;
}
export interface NewGameMethod extends MeteorMethod {
name: "newGame";
args: [auth: ClientWithSecret];
return: string;
}
export interface NewClientMethod extends MeteorMethod {
name: "newClient";
args: [];
return: ClientWithSecret;
}
export interface LoginMethod extends MeteorMethod {
name: "login";
args: [auth: ClientWithSecret];
return: true | ClientWithSecret;
}
export type ValidMethods = NewGameMethod | NewClientMethod | LoginMethod;
Now I am trying to create a method that wraps the normal meteor method (which use callbacks...) to a function returning a promise like this:
export function meteorCallAsync<T extends MeteorMethod>(methodName: T["name"], args: T["args"]): Promise<T["return"]> {
return new Promise((resolve, reject) => {
Meteor.call(methodName, ...args, (error: Meteor.Error, result: T["return"]) => {
if (error) {
reject(error);
}
resolve(result);
});
});
}
This seems to be working like a charm. I can await a meteor method like that
const retVal = await meteorCallAsync<NewGameMethod>("newGame", [getClientWithSecret()]);
and TypeScript actually checks if the string "newGame"
is equal to the string literal defined as the name of NewGameMethod
. Perfect, but I have two questions, since I'm new to TypeScript:
Is it possible to omit the first parameter to meteorCallAsync
altogether and let the TypeScript compiler fill it in? I am already defining the type as a generic, so the compiler does have the necessary information to fill this in but I have no idea if this is supported in TypeScript
Is there a way to define the MeteorMethod
interface as some kind of abstract interface which cannot be instantiated? Would the ValidMethods
actually be a more appropriate type for meteorCallAsync<T extends ValidMethods>
and is there a way for me to enforce that each method actually has to have a name
, args
, and return
?
EDIT:
I have added my implementation of the newGame
method below. The problem is that I have no idea how to tell TypeScript that Meteor.call(name, ...args, (error, result)=>{})
actually calls the function defined in Meteor.methods
Meteor.methods({
// create a new game
newGame(auth: ClientWithSecret) {
if (!isValidClient(auth)) {
console.error(`client invalid ${auth.id}`);
return;
}
let randomId,
newIdFound = false;
while (!newIdFound) {
randomId = Random.id();
const game = GamesCollection.findOne({ _id: randomId });
if (!game) {
newIdFound = true;
}
}
GamesCollection.insert({
_id: randomId,
hostId: auth.id,
clientIds: [auth.id],
players: [],
createdAt: new Date(Date.now()),
});
return randomId;
},
newClient(): ClientWithSecret {
//implementation
},
login(auth: ClientWithSecret): true | ClientWithSecret {
// returns true if login successful, new ClientWithSecret if credentials invalid
},
});
You shouldn't have to type out interfaces for every function because that information already exists somewhere in your code base. If you know they type of the function itself then you can use ReturnType and Parameters to derive the types for the args and the return from the type of the function. The part that we are missing here is the association between the function names and the function types.
I'm not familiar with Meteor so I've had to look at the docs to see how this works. It turns out that the types are very loosely defined.
Meteor.call()
allows you to pass any function name with any arguments.
function call(name: string, ...args: any[]): any;
It is smart to create a wrapper around this function like you are doing here. You will get better type-safety and better autocomplete support. You could possibly use declaration merging to augment the package types, but wrapping it is easier to implement.
The callable function names get defined by calling Meteor.methods()
with a dictionary object of methods.
function methods(methods: {[key: string]: (this: MethodThisType, ...args: any[]) => any}): void;
We want to get the type of your specific dictionary. We will use an intermediate variable rather than defining the methods inside of Meteor.methods()
so that we can use typeof
on that variable to get your dictionary type.
// define the methods
const myMethods = {
newGame(auth: ClientWithSecret) {
....
}
// set the methods on Meteor
Meteor.methods(myMethods);
// get the type
type MyMeteorMethods = typeof myMethods;
We then use that MyMeteorMethods
type to annotate your meteorCallAsync
function.
export function meteorCallAsync<T extends keyof MyMeteorMethods>(
methodName: T,
...args: Parameters<MyMeteorMethods[T]>
): Promise<ReturnType<MyMeteorMethods[T]>> {
return new Promise((resolve, reject) => {
Meteor.call(methodName, ...args, (error: Meteor.Error, result: ReturnType<MyMeteorMethods[T]>) => {
if (error) {
reject(error);
}
resolve(result);
});
});
}
T
is the method name, which must be a key on your dictionary.MyMeteorMethods[T]
is type for the method.args
match the parameters of the method. I changed args
to ...args
so that you can pass arguments individually instead of in an array.Promise
of the method's return type.Now when you call the function you don't need to set any types. Typescript can infer the correct types based on the methodName
. You get errors where you want them and no errors where you don't.
const x = async () => {
// ok to call with the correct arguments
const retVal1 = await meteorCallAsync("newGame", getClientWithSecret());
// error if required arguments are missing
// 'Arguments for the rest parameter 'args' were not provided.'
const retVal2 = await meteorCallAsync("newGame");
// ok to call with no arguments if the method doesn't require any
const retVal3 = await meteorCallAsync("newClient");
// error if calling an invalid method name
// 'Argument of type '"invalidFunc"' is not assignable to parameter of type '"newGame" | "newClient" | "login"''
const retVal4 = await meteorCallAsync("invalidFunc");
}
If you want to make use of this
inside of any methods in your methods object, that requires some finessing. Some of the types that we want to use here (such as MethodThisType
) aren't exported so we need to work backwards to get them.
type MeteorMethodDict = Parameters<typeof Meteor.methods>[0]
This gives us the type for that method dictionary object where each entry is a function with a this
type of MethodThisType
.
We want to make sure that your methods extend the MeteorMethodDict
type without widening the type and losing information about your specific methods. So we can create the methods through an identity function that enforces the type.
const makeMethods = <T extends MeteorMethodDict>(methods: T): T => methods;
Now you can use this
inside any of the methods and it will have the correct type.
const myMethods = makeMethods({
newGame(auth: ClientWithSecret) {
const userId = this.userId;
...
The type that we get from type MyMeteorMethods = typeof myMethods
will include the this
type whether you use it or not.
type MyMeteorMethods = {
newGame(this: Meteor.MethodThisType, auth: ClientWithSecret): any;
newClient(this: Meteor.MethodThisType): ClientWithSecret;
login(this: Meteor.MethodThisType, auth: ClientWithSecret): true | ClientWithSecret;
}