typescriptcode-generationtype-mapping

Compile TypeScript Types for runtime use


I have a client server application that communicates using REST calls.

To prevent that I accedently use the wrong types I defined all RestCalls in a common file (excerpt):

type def<TConnection extends Connections> =
    // Authentication
    TConnection extends '/auth/password/check/:login->get' ? Set<void, { found: boolean }, void, false>
    : TConnection extends '/auth/password/register->post' ? Set<RegsiterAccount<Login>, void, void, false>
    : TConnection extends '/auth/password/login->post' ? Set<Login, void, void, false>
    : TConnection extends '/auth/webauth/challenge->get' ? Set<void, {
        challenge: string,
        id: string
    }, void, false>
    : TConnection extends '/auth/webauth/register->post' ? Set<RegsiterAccount<WebAuthN> & { comment: string }, void, void, false>
    : TConnection extends '/auth/webauth/login->post' ? Set<Assertion, void, void, false>
    : TConnection extends '/auth/logout->post' ? Set<void, void, void>
    : TConnection extends '/auth/invite->get' ? Set<void, {
        link: string,
        validUntill: string
    }, void>
    : TConnection extends '/auth/invite/validate->post' ? Set<{ invite: string }, {
        granted_by: string,
        validUntill: string
    }, void, false>
    : TConnection extends '/auth/isAuthenticated->get' ? Set<void, {
        isAuthenticated: boolean,
        userName: string | undefined
    }, void, false>

    // default
    : never

The url and method are encoded in the string, it also uses express url parameters (/:). Set defines the data in the body and if the server should check authentication

  1. request
  2. response
  3. response on error
  4. if authentication is needed
type Set<Input extends (Object | void), result extends (Object | void), Error extends string | object | void, NeedsAuthentication extends boolean = true> = {
    input: Input, result: result,
    error: DefaultError<Error>,
    authenticated: NeedsAuthentication
};

I can then use following types to get the correct values

export type InputBody<TPath extends Connections> = def<TPath>['input'];
export type InputPath<TPath extends Connections> = express.RouteParameters<TPath>;
export type Input<TPath extends Connections> = InputBody<TPath> & InputPath<TPath>;


export type Result<TPath extends Connections> = def<TPath>['result']
export type NeedsAuthentication<TPath extends Connections> = def<TPath>['authenticated']

export type Error<TPath extends Connections> = def<TPath>['error']

export type Method<T extends string> = T extends `${infer path}->${infer method}`
    ? method extends METHODS ? method
    : never
    : never;
export type Path<T extends string> = T extends `${infer path}->${infer method}`

    ? method extends METHODS ? path
    : never
    : never;

I would now like to know at runtime if for a specific call authentication is required.

I could encode it in the url like I did for the method. Or use NeedsAuthentication as a paramter where the url is also provided. That way when I put in the URL I get autocomplete for that will only have true if authentication is needed otherwise false.

I don't like both workarounds.

What I would like to do is

const needsAuthentication :boolean = NeedsAuthentication<'/auth/webauth/login->post'>;

Is there any way to tell the compiler to compile the types in the output JS so I cann do the same thing the compiler does when interfereing what a parameter needs to be?

My only other solution currently is writing a script that is executed prebuild which parses my definition and put out a map that contains for every URL if it needs authentication using some regex parsing...


EDIT

I would like to implement following function

function needsAuthentication<T extends Connections>(test:T):NeedsAuthentication<T> {
    // todo find out if authentication is actual required for this url 
}

which is not part of the transmited data but encoded in the type mapping.

The compiler will nicely map it to true or false sample completion 1 sample completion 2 for const strings on compieltime (the actual value would still not be emiited...) sample completion 3

I could try to use the typescript compiler and call whatever function evaluates the return values...


Solution

  • I created an script that runs pre build and uses the TypeScript compilers typechecker to infer the retunrtype of the function for every possible input. Then this is emited to a file which then is used by the function.

    script

        const project = new Project({});
    
        // add source files
        project.addSourceFilesAtPaths("src/**/*.ts");
    
    
        function Test(path: string) {
            const dataFile = project.createSourceFile(`src/${randomUUID()}.ts`, `import * as x from "./data" ; const check = x.needsAuthentication("${path}")`);
            const declaraiton = dataFile.getVariableDeclarationOrThrow('check');
            const result = project.getTypeChecker().getTypeText(declaraiton.getType());
            return result.toLowerCase() == 'true';
        }
        const pathChecks = paths.map(x => [x, Test(x)]).reduce((p: any, v: any) => {
            p[v[0]] = v[1];
            return p;
        }, {});
    
        let authenticationText = `export const lookup = ${JSON.stringify(pathChecks)} as const;`
        await fs.writeFile('src/data-authentication.g.ts', authenticationText);
    

    In my code I use

    import { lookup } from './data-authentication.g';
    //...
    export function needsAuthentication<T extends Connections>(test: T): NeedsAuthentication<T> {
        return lookup[test];
    }
    

    Its not the fastest way to do it but it works...