1

In Typescript ^3.8, given this interface...

interface IEndpoint { method: 'get'|'put'|'post'|'patch'|'delete', path: string }

and this constant...

const endpoint = { method: 'get', path: '/first/:firstId/second/:secondId' }

Note that :firstId and :secondId are path parameters that will be dynamically provided at runtime. I have a function that will take the endpoint and an object with param values, and return the url.

function buildEndpointUrl(endpoint: IEndpoint, map: {[key: string]: string}): string;

So, for instance:

// will set url to '/first/123/second/456'
const url = buildEndpointUrl(endpoint, {firstId: '123', secondId: '456'});

The challenge I'm facing is that the compiler will allow garbage to be passed as the 2nd param: how do I define IEndpoint and buildEndpointUrl so that the compiler throws an error if the object provided as the second parameter is missing a required key?

Here is what I've tried:

interface IEndpoint<T extends ReadonlyArray<string>> { 
  method: 'get'|'put'|'post'|'patch'|'delete', 
  path: string
}

const endpoint: IEndpoint<['firstId', 'secondId']> = {...};

function buildEndpointUrl<T extends ReadonlyArray<string>>(
  endpoint: IEndpointConfig<T>, 
  map: {[key: T[number]]: string} // compiler error
);

the last line throws a compiler error:

TS1023: An index signature parameter must be either "string" or "number"

I expected T[number] to be equivalent to string since T extends ReadonlyArray<string> but apparently not. How should I setup my definition to add type safety?

5
  • At least [key: T[number]]: string should be [key: number]: string Commented Nov 17, 2020 at 5:44
  • 1
    I don't think that would work. It will not enforce the parameter to have the required properties. With my example above, if I supply IEndpoint<['firstId', 'secondId']> as the first function param, I want Typescript to infer that the second param must be of type {firstId: string, secondId: string} Commented Nov 17, 2020 at 5:49
  • Should a call look like buildEndpointUrl(endpoint, ['firstId', 'secondId']);? Commented Nov 17, 2020 at 5:54
  • No;the 2nd param is an object that is used to map the name of the route param ('firstId') to its value ('123'). I gave an example in my post. Commented Nov 17, 2020 at 5:56
  • I feel like this question and answer is going to be useful for you: stackoverflow.com/questions/64744734/… Commented Nov 17, 2020 at 6:08

2 Answers 2

2

You just need a mapped type instead of an index signature. The predefined mapped type Record will work

export interface IEndpoint<T extends ReadonlyArray<string>> { 
  method: 'get'|'put'|'post'|'patch'|'delete', 
  path: string
}

const endpoint: IEndpoint<['firstId', 'secondId']> =  { method: 'get', path: '/first/:firstId/second/:secondId' };

declare function buildEndpointUrl<T extends ReadonlyArray<string>>(
  endpoint: IEndpoint<T>, 
  map: Record<T[number],string> // compiler error
): void;

const b = buildEndpointUrl(endpoint, { firstId: "", secondId:"", test: "" })

Playground Link

Note in 4.1 you can also use template literal types to actually extract the parameters from the path string

export interface IEndpoint<T extends string> { 
  method: 'get'|'put'|'post'|'patch'|'delete', 
  path: T
}

type ExtractParameters<T extends string> = 
  T extends `${infer Prefix}/:${infer Param}/${infer Suffix}` ? Record<Param, string> & ExtractParameters<Suffix> & [Prefix, Suffix, Param] :
  T extends `${infer Prefix}/:${infer Param}` ? Record<Param, string>  :
  T extends `:${infer Param}`? Record<Param, string> :
  { T: T}

type X = "second/:secondId" extends `${infer Prefix}/:${infer Param}/${infer Suffix}` ? [Prefix, Param, Suffix] : "";
type Y = ExtractParameters<"/first/:firstId/second/:secondId">

const endpoint =  { method: 'get', path: '/first/:firstId/second/:secondId' } as const

declare function buildEndpointUrl<T extends string>(
  endpoint: IEndpoint<T>, 
  map: ExtractParameters<T>
): void;

const b = buildEndpointUrl(endpoint, { firstId: "", secondId:"", test: "" })

Playground Link

Sign up to request clarification or add additional context in comments.

Comments

1

You're almost got it:

type EndpointParams = ReadonlyArray<string>;

interface IEndpoint<T extends EndpointParams> { 
  method: 'get'|'put'|'post'|'patch'|'delete', 
  path: string
}

function buildEndpointUrl<T extends EndpointParams>(
  endpoint: IEndpoint<T>, 
  map: {[key in T[number]]: string} // In your case it should be mapped, not just indexed
) {}

const endpoint: IEndpoint<['first', 'second']> = {
    method: "get",
    path: "",
};

buildEndpointUrl(endpoint, { // failed
    first: "v1",
    p2: "v2",
});

buildEndpointUrl(endpoint, { // passed
    first: "v1",
    second: "v2",
});

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.