简体   繁体   中英

Typescript: typesafety for object must have all keys in array values

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?

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

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",
});

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM