ThomasReggi
ThomasReggi

Reputation: 59365

Function library with defined types

I am trying to compose functions in a way where I can define a name, input, and return types and then access them from a central function. However, when I do this I lose typing information.

How can I build a system like this without hard-coded value types?

import * as fs from 'fs';
import { promisify } from 'util';
import * as lodash from 'lodash';

const libs = []

export enum Types {
    number,
    numbers,
    string,
    buffer,
}

export enum Actions {
    add,
    subtract,
    readFile,
}

libs.push({
    action: Actions.add,
    from: Types.numbers,
    get: Types.number,
    fn: (...n: number[]): number => n.reduce((a, b) => a + b, 0),
})

libs.push({
    action: Actions.subtract,
    from: Types.numbers,
    get: Types.number,
    fn: (...n: number[]): number => n.reduce((a, b) => a - b, 0),
})

libs.push({
    action: Actions.readFile,
    from: Types.string,
    get: Types.string,
    fn: async (s:string): Promise<string> => promisify(fs.readFile)(s, 'UTF8'),
})

libs.push({
    action: Actions.readFile,
    from: Types.string,
    get: Types.buffer,
    fn: async (s:string): Promise<Buffer> => promisify(fs.readFile)(s),
})

const library = (a: Actions, from: Types, get: Types, lib) => {
    const found = lodash.find(lib, fn => {
        return (
        lodash.isEqual(fn.from, from) &&
        lodash.isEqual(fn.get, get)
        );
    });
    if (!found) throw new Error('no conversion');
    return found.fn;
}

const { readFile } = Actions;
const { string: s } = Types;

const x = library(readFile, s, s, libs)

x('./tres.ts').then(console.log)

How can I retain typing information of x?

Upvotes: 1

Views: 55

Answers (1)

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

Reputation: 249656

We need to preserve in libs the type of the actual items in the array. The easiest way to do this is with a help of an extra function that will infer the type for libs based on the actual items in the array (including literal types for where Actions and Types are used).

With this information we can type the library function to extract the type of the function from libs that has the same action, get and from as the passed in types:

import * as fs from 'fs';
import { promisify } from 'util';
import * as lodash from 'lodash';


export enum Types {
    number,
    numbers,
    string,
    buffer,
}

export enum Actions {
    add,
    subtract,
    readFile,
}

function makeLib<T extends Array<{action : A, from: F, get: G, fn: (...a: any[])=> any}>, A extends Actions, F extends Types, G extends Types>(...a:T){
    return a;
}
const libs = makeLib({
    action: Actions.add,
    from: Types.numbers,
    get: Types.number,
    fn: (...n: number[]): number => n.reduce((a, b) => a + b, 0),
}, {
    action: Actions.subtract,
    from: Types.numbers,
    get: Types.number,
    fn: (...n: number[]): number => n.reduce((a, b) => a - b, 0),
}, {
    action: Actions.readFile,
    from: Types.string,
    get: Types.string,
    fn: async (s:string): Promise<string> => promisify(fs.readFile)(s, 'UTF8'),
}, {
    action: Actions.readFile,
    from: Types.string,
    get: Types.buffer,
    fn: async (s:string): Promise<Buffer> => promisify(fs.readFile)(s),
})

const library = <T extends Array<{action : Actions, from: Types, get: Types, fn: (...a: any[])=> any}>, A extends Actions, F extends Types, G extends Types>(a: A, from: F, get: G, lib: T) => {
    const found = lodash.find(lib, fn => {
        return (
        lodash.isEqual(fn.from, from) &&
        lodash.isEqual(fn.get, get)
        );
    });
    if (!found) throw new Error('no conversion');
    return found.fn as Extract<T[number], {action : A, from: F, get: G }>['fn'];
}

const { readFile } = Actions;
const { string: s } = Types;

const x = library(readFile, s, s, libs) // x is (s: string) => Promise<string

x('./tres.ts').then(console.log)

const x2 = library(Actions.subtract, Types.string, Types.string, libs)  // never
const x3 = library(Actions.subtract, Types.numbers, Types.number, libs)  //  (...n: number[]) => number

You can also use strings instead of enums:

function makeLib<T extends Array<{action : V, from: V, get: V, fn: (...a: any[])=> any}>, V extends string>(...a:T){
    return a;
}
const libs = makeLib({
    action: "add",
    from: "numbers",
    get: "number",
    fn: (...n: number[]): number => n.reduce((a, b) => a + b, 0),
}, {
    action: "subtract",
    from: "numbers",
    get: "number",
    fn: (...n: number[]): number | null => n.reduce((a, b) => a - b, 0),
}, {
    action: "readFile",
    from: "string",
    get: "string",
    fn: async (s:string): Promise<string> => promisify(fs.readFile)(s, 'UTF8'),
}, {
    action: "readFile",
    from: "string",
    get: "buffer",
    fn: async (s:string): Promise<Buffer> => promisify(fs.readFile)(s),
})

const library = <T extends Array<{action : string, from: string, get: string, fn: (...a: any[])=> any}>, 
    A extends T[number]['action'], F extends T[number]['from'], G extends T[number]['get']>(a: A, from: F, get: G, lib: T) => {
    const found = lodash.find(lib, fn => {
        return (
        lodash.isEqual(fn.from, from) &&
        lodash.isEqual(fn.get, get)
        );
    });
    if (!found) throw new Error('no conversion');
    return found.fn as Extract<T[number], {action : A, from: F, get: G }>['fn'];
}

const { readFile } = Actions;
const { string: s } = Types;

const x = library("readFile", "string", "string", libs) // x is (s: string) => Promise<string

x('./tres.ts').then(console.log)

const x2 = library("subtract", "string", "string", libs)  // never
const x3 = library("subtract", "numbers", "number", libs)  //  (...n: number[]) => number

Upvotes: 2

Related Questions