typescript - Type safety while looping over Object.entries() - Stack Overflow

I'm working in a monorepo where I define API schemas in a shared module using Zod. These schemas s

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
Add a comment  | 

1 Answer 1

Reset to default 1

A 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:

  1. 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).

  1. 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条)

  • 暂无评论

联系我们

400-800-8888

在线咨询: QQ交谈

邮件:admin@example.com

工作时间:周一至周五,9:30-18:30,节假日休息

关注微信