I'm working in a monorepo where I define API schemas in a shared module using Zod. These schemas specify endpoints, HTTP methods, and response types, ensuring type safety between the client and server.
I want to follow the DRY principle and store all this information in a single schema object, which the backend uses to dynamically create Express routes.
Problem:
When looping over Object.entries(), TypeScript treats the inner objects as potentially missing some methods. This causes type widening, losing method-specific type safety. Additionally, I want TypeScript to narrow down possible methods inside the loop, based on conditions like if (method === 'GET') continue;.
Question
How can I achieve full type safety when looping over Object.entries() on an object where some endpoints have different methods? Specifically:
Ensure method is correctly inferred for each endpoint. Allow TypeScript to narrow down the possible methods inside the loop after filtering. Is this possible in TypeScript? Or is there a fundamental limitation when iterating over objects like this?
Approach 1
Using a generic TypeScript helper function (Entries)
TypeScript Playground
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Schema = {
[endpoint: string]: Partial<{
[method in HttpMethod]: {
response: Record<string, any>
}
}>
};
const schema = {
'/api/user': {
GET: {
response: {
id: 1,
},
},
DELETE: {
response: {
id: 1,
},
},
},
'/api/log': {
GET: {
response: {
dateTime: Date
}
},
POST: {
response: {
dateTime: Date
}
}
}
} as const satisfies Schema;
type Entries<T> = {
[K in keyof T]: [K, T[K]];
}[keyof T][];
for (const [endpoint, methodDetails] of Object.entries(schema) as Entries<typeof schema>) {
for (const [method, details] of Object.entries(methodDetails) as Entries<typeof methodDetails>) {
if (endpoint !== '/api/log' || method === 'GET') {
continue;
}
// I want typescript to know at this point, that the only availble HttpMethod is 'POST', because of the continue statement above.
console.log(method);
}
}
Approach 2
Using a TypeScript helper function (HelperGetValidMethodsForEndpoint)
TypeScript Playground
const methods = [ 'GET', 'POST', 'PUT' ] as const;
type Method = typeof methods[number];
const responseCodes = [ '200', '400', '500' ] as const;
type ResponseCode = typeof responseCodes[number];
type Schema = Record<
string,
Partial<Record<
Method,
{
details: string,
responses: Partial<Record<ResponseCode, {
content: {
'application/json': {
schema: any
}
}
}>>,
}
>>
>;
const schema = {
'/api/test1': {
'GET': {
details: "",
responses: {
'200': {
content: {
'application/json': {
schema: null
}
}
},
}
} as const,
'POST': {
details: "",
responses: {
'200': {
content: {
'application/json': {
schema: null
}
}
},
}
} as const,
},
'/api/test2': {
'POST': {
details: "",
responses: {
'200': {
content: {
'application/json': {
schema: null
}
}
},
}
} as const,
},
'/api/test3': {
'PUT': {
details: "",
responses: {
'200': {
content: {
'application/json': {
schema: null
}
}
},
}
} as const,
},
} as const satisfies Schema;
type Endpoint = keyof typeof schema;
type EndpointMethod = typeof schema[Endpoint];
type HelperGetValidMethodsForEndpoint<Endpoint extends keyof typeof schema> = Extract<Method, keyof (typeof schema)[Endpoint]>;
const schemaEntries = Object.entries(schema) as [Endpoint, EndpointMethod][];
for (const [ endpoint, methodObject ] of schemaEntries) {
const methodObjectEntries = Object.entries(methodObject) as [ keyof typeof methodObject, (typeof methodObject)[keyof typeof methodObject] ][];
for (const [ method, methodProperties ] of methodObjectEntries) {
// 'method' and 'methodProperties' are typed as 'never', because there is no common method/methodProperties between the endpoints.
// If for example, I'd remove '/api/test3', the method will be typed as 'POST'
// The helper doesn't work inside the loop either with type Endpoint or 'typeof endpoint', still typed as 'never'
type MethodFromHelper = HelperGetValidMethodsForEndpoint<Endpoint>;
console.log(endpoint, method, methodProperties);
}
};
type MethodNeverFromHelper = HelperGetValidMethodsForEndpoint<Endpoint>;
// The helper only works when I input the endpoint as a specific value.
type MethodPutFromHelper = HelperGetValidMethodsForEndpoint<'/api/test3'>;
Approach 3
Using a 'typedEntries' function
TypeScript Playground
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Schema = {
[endpoint: string]: Partial<{
[method in HttpMethod]: {
response: Record<string, any>
}
}>
};
const schema = {
'/api/user': {
GET: {
response: {
id: 1,
},
},
DELETE: {
response: {
id: 1,
},
},
},
'/api/log': {
GET: {
response: {
id: 1,
}
},
POST: {
response: {
id: 1,
}
}
}
} as const satisfies Schema;
const typedEntries = <T extends Record<string, any>>(obj: T) => {
return Object.entries(obj) as { [K in keyof T]: [K, T[K]] }[keyof T][];
};
for (const [endpoint, methodDetails] of typedEntries(schema)) {
for (const [method, details] of typedEntries(methodDetails)) {
if (method === 'GET') {
continue;
}
console.log(method); // method is typed as 'never', although in runtime, it could be DELETE or POST
}
}
I'm working in a monorepo where I define API schemas in a shared module using Zod. These schemas specify endpoints, HTTP methods, and response types, ensuring type safety between the client and server.
I want to follow the DRY principle and store all this information in a single schema object, which the backend uses to dynamically create Express routes.
Problem:
When looping over Object.entries(), TypeScript treats the inner objects as potentially missing some methods. This causes type widening, losing method-specific type safety. Additionally, I want TypeScript to narrow down possible methods inside the loop, based on conditions like if (method === 'GET') continue;.
Question
How can I achieve full type safety when looping over Object.entries() on an object where some endpoints have different methods? Specifically:
Ensure method is correctly inferred for each endpoint. Allow TypeScript to narrow down the possible methods inside the loop after filtering. Is this possible in TypeScript? Or is there a fundamental limitation when iterating over objects like this?
Approach 1
Using a generic TypeScript helper function (Entries)
TypeScript Playground
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Schema = {
[endpoint: string]: Partial<{
[method in HttpMethod]: {
response: Record<string, any>
}
}>
};
const schema = {
'/api/user': {
GET: {
response: {
id: 1,
},
},
DELETE: {
response: {
id: 1,
},
},
},
'/api/log': {
GET: {
response: {
dateTime: Date
}
},
POST: {
response: {
dateTime: Date
}
}
}
} as const satisfies Schema;
type Entries<T> = {
[K in keyof T]: [K, T[K]];
}[keyof T][];
for (const [endpoint, methodDetails] of Object.entries(schema) as Entries<typeof schema>) {
for (const [method, details] of Object.entries(methodDetails) as Entries<typeof methodDetails>) {
if (endpoint !== '/api/log' || method === 'GET') {
continue;
}
// I want typescript to know at this point, that the only availble HttpMethod is 'POST', because of the continue statement above.
console.log(method);
}
}
Approach 2
Using a TypeScript helper function (HelperGetValidMethodsForEndpoint)
TypeScript Playground
const methods = [ 'GET', 'POST', 'PUT' ] as const;
type Method = typeof methods[number];
const responseCodes = [ '200', '400', '500' ] as const;
type ResponseCode = typeof responseCodes[number];
type Schema = Record<
string,
Partial<Record<
Method,
{
details: string,
responses: Partial<Record<ResponseCode, {
content: {
'application/json': {
schema: any
}
}
}>>,
}
>>
>;
const schema = {
'/api/test1': {
'GET': {
details: "",
responses: {
'200': {
content: {
'application/json': {
schema: null
}
}
},
}
} as const,
'POST': {
details: "",
responses: {
'200': {
content: {
'application/json': {
schema: null
}
}
},
}
} as const,
},
'/api/test2': {
'POST': {
details: "",
responses: {
'200': {
content: {
'application/json': {
schema: null
}
}
},
}
} as const,
},
'/api/test3': {
'PUT': {
details: "",
responses: {
'200': {
content: {
'application/json': {
schema: null
}
}
},
}
} as const,
},
} as const satisfies Schema;
type Endpoint = keyof typeof schema;
type EndpointMethod = typeof schema[Endpoint];
type HelperGetValidMethodsForEndpoint<Endpoint extends keyof typeof schema> = Extract<Method, keyof (typeof schema)[Endpoint]>;
const schemaEntries = Object.entries(schema) as [Endpoint, EndpointMethod][];
for (const [ endpoint, methodObject ] of schemaEntries) {
const methodObjectEntries = Object.entries(methodObject) as [ keyof typeof methodObject, (typeof methodObject)[keyof typeof methodObject] ][];
for (const [ method, methodProperties ] of methodObjectEntries) {
// 'method' and 'methodProperties' are typed as 'never', because there is no common method/methodProperties between the endpoints.
// If for example, I'd remove '/api/test3', the method will be typed as 'POST'
// The helper doesn't work inside the loop either with type Endpoint or 'typeof endpoint', still typed as 'never'
type MethodFromHelper = HelperGetValidMethodsForEndpoint<Endpoint>;
console.log(endpoint, method, methodProperties);
}
};
type MethodNeverFromHelper = HelperGetValidMethodsForEndpoint<Endpoint>;
// The helper only works when I input the endpoint as a specific value.
type MethodPutFromHelper = HelperGetValidMethodsForEndpoint<'/api/test3'>;
Approach 3
Using a 'typedEntries' function
TypeScript Playground
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Schema = {
[endpoint: string]: Partial<{
[method in HttpMethod]: {
response: Record<string, any>
}
}>
};
const schema = {
'/api/user': {
GET: {
response: {
id: 1,
},
},
DELETE: {
response: {
id: 1,
},
},
},
'/api/log': {
GET: {
response: {
id: 1,
}
},
POST: {
response: {
id: 1,
}
}
}
} as const satisfies Schema;
const typedEntries = <T extends Record<string, any>>(obj: T) => {
return Object.entries(obj) as { [K in keyof T]: [K, T[K]] }[keyof T][];
};
for (const [endpoint, methodDetails] of typedEntries(schema)) {
for (const [method, details] of typedEntries(methodDetails)) {
if (method === 'GET') {
continue;
}
console.log(method); // method is typed as 'never', although in runtime, it could be DELETE or POST
}
}
Share
Improve this question
edited Mar 4 at 14:49
Patrick Miah
asked Mar 4 at 13:46
Patrick MiahPatrick Miah
537 bronze badges
1 Answer
Reset to default 1A union of objects doesn't work as you expect. Retrieving keys of a union of objects returns the intersection of the keys of the constituents, which are the ones that are guaranteed to be in the union.
The type MethodDetails = (typeof schema)[keyof typeof schema];
gives you a union of possible values of methodDetails
(from for (const [endpoint, methodDetails] of typedEntries(schema))
). Only GET
is the common key of the union, hence the inferred literal type 'GET'
of the method
variable from for (const [method, details] of typedEntries(methodDetails))
.
If you try to get all keys of a union, and change the typedEntries
function to be:
type KeysOfUnion<T> = T extends T ? keyof T: never;
const typedEntries = <T extends Record<string, any>>(obj: T) => {
type AllKeys = KeysOfUnion<T>;
return Object.entries(obj) as { [K in AllKeys]: [K, T[K]] }[AllKeys][];
};
...while the keys would be inferred correctly, the values would not. That's because the types of the values of the non-common keys couldn't be inferred. In this case, the type received for the value isn't T[K]
, but unknown
, which absorbs everything else.
So for the solution, you can try one of the following:
- Make all methods required, and omit those you don't need with
undefined
.
type Schema = {
[endpoint: string]: {
[method in HttpMethod]: {
response: Record<string, any>
} | undefined;
}>;
};
This will make key inference consistent every time (but you'll need null checks).
- Use type narrowing with
in
operator if you have some specialized logic with specific methods. For example, using the current schema object from your question:
if ('POST' in methodDetails) {
methodDetails; // { GET: ...; POST: ... }
} else {
methodDetails; // { GET: ...; DELETE: ... }
}
发布者:admin,转转请注明出处:http://www.yc00.com/questions/1745040276a4607768.html
评论列表(0条)