Reputation: 40886
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?
Upvotes: 1
Views: 1750
Reputation: 249506
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: "" })
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: "" })
Upvotes: 2
Reputation: 1337
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",
});
Upvotes: 1