patrick
patrick15mo ago

Is it possible to narrow an output schema if the query optionally doesn't return all fields?

I have a router procedure that has an input schema that has an optional filter that changes the shape of the data to only include those fields (like sql select), but those records will fail the output schema which contains all of the fields as required. is there a way to construct a .output() specification that narrows the type, possibly using z.partial() so that these partial "rows" will pass output validation?
16 Replies
Nick
Nick15mo ago
union or discriminatedUnion probably
patrick
patrick15mo ago
export function makeRouter<Schema extends z.ZodSchema>(
tableName: string,
schema: Schema
) {
const client = getTableClient(tableName);
return router({
list: procedure
.input(
z
.object({
select: z.string().array().optional()
})
.optional()
)
// how do i set the output schema to .pick only the fields that are passed into query?
.output(schema.array() as z.ZodArray<Schema>)
.query(async ({ input: options }) => {
// if options.select is set here, the result will not have all of the required fields in schema
return await queryRows(client, options);
})
});
}
export function makeRouter<Schema extends z.ZodSchema>(
tableName: string,
schema: Schema
) {
const client = getTableClient(tableName);
return router({
list: procedure
.input(
z
.object({
select: z.string().array().optional()
})
.optional()
)
// how do i set the output schema to .pick only the fields that are passed into query?
.output(schema.array() as z.ZodArray<Schema>)
.query(async ({ input: options }) => {
// if options.select is set here, the result will not have all of the required fields in schema
return await queryRows(client, options);
})
});
}
this is querying Azure Table Storage. options.select narrows the columns returned to the list specified. the use case is someone wants to display a <Select> of just the id and name fields from an entity that may have many more columns in the zod schema. e.g. const { data } = api.entity.list.useQuery({ select: ["id", "name"] }); are the input fields available to the .output() step?
Nick
Nick15mo ago
RIGHT sorry I did misunderstand You might be best to leave output blank and do this in your query itself Output is just a way to define a static validator, but you could build a Zod validator at runtime based on the input and validate the output if needed, or just set up some plain TS types which are built from the input
patrick
patrick15mo ago
oh! that sounds promising... so is there a zod method to construct a narrowed type?
Nick
Nick15mo ago
For hints, take a look at how Zod handles Pick and Omit, it has some TS under the hood which does this, but also yes it has those methods
patrick
patrick15mo ago
so as you can see above, i've declared my schema as a generic that extends z.ZodSchema... but it doesn't have .pick().. have i chosen the wrong type?
Nick
Nick15mo ago
You’re close But you don’t need to be casting your Zod type Just extend from ZodType and let the inference do the rest Or ZodObject possibly in your case
patrick
patrick15mo ago
function makeRouter<Schema extends z.AnyZodObject>( tableName: string, schema: Schema, ... like this?
Nick
Nick15mo ago
That should give you access to the methods you want yes
patrick
patrick15mo ago
then just split on if there are selected fields and call parse separately? .query(async ({ input: options }) => { const result = await queryRows(client, options); if (options?.select) { const picker = options.select.reduce<Record<string, true>>((a, f) => { a[f] = true; return a; }, {}); const resultSchema = schema.pick(picker).array(); resultSchema.parse(result); } else { schema.array().parse(result); } return result; }),
Nick
Nick15mo ago
Looks like you’re on the right track yep 👍 How typescript infers this might be something to refine a bit But you’ll figure out fast if it’s an issue! 😆
patrick
patrick15mo ago
thanks for the tips! ugh. some of my schema inputs are discriminated unions and ZodDiscriminatedUnion is not assignable to parameter of type 'AnyZodObject'.
Nick
Nick15mo ago
You are probably doing something inadvisable Might have to simplify you use case, or pass a Zod factory method instead so you can pull up the pick/omit to where the unions get created But this does all smell a bit like you’re trying to rewrite GraphQL and Zod isn’t really a great choice for this
patrick
patrick15mo ago
not coming from GraphQL... just trying to store discriminated unions in the same azure table... this is where i ended up with the discriminated union.
import { z } from "zod";

const baseTableEntitySchema = z.object({
id: z.string().uuid().default("00000000-0000-0000-0000-000000000000").describe("the generated uuid rowKey.")
});

const baseCollectorSchema = z
.object({
name: z.string().min(1).describe("display name of collector")
})
.merge(baseTableEntitySchema);

const kustoParametersSchema = z.object({
query: z.string().min(1).describe("the kql query")
});
const kSchema = z
.object({
type: z.literal("kusto"),
parameters: kustoParametersSchema
})
.merge(baseCollectorSchema);

const metricsParametersSchema = z.object({
namespace: z.string().min(1).describe("the Azure metrics namespace")
});
const mSchema = z
.object({
type: z.literal("metrics"),
parameters: metricsParametersSchema
})
.merge(baseCollectorSchema);

const collectorSchema = z.discriminatedUnion("type", [mSchema, kSchema]);
import { z } from "zod";

const baseTableEntitySchema = z.object({
id: z.string().uuid().default("00000000-0000-0000-0000-000000000000").describe("the generated uuid rowKey.")
});

const baseCollectorSchema = z
.object({
name: z.string().min(1).describe("display name of collector")
})
.merge(baseTableEntitySchema);

const kustoParametersSchema = z.object({
query: z.string().min(1).describe("the kql query")
});
const kSchema = z
.object({
type: z.literal("kusto"),
parameters: kustoParametersSchema
})
.merge(baseCollectorSchema);

const metricsParametersSchema = z.object({
namespace: z.string().min(1).describe("the Azure metrics namespace")
});
const mSchema = z
.object({
type: z.literal("metrics"),
parameters: metricsParametersSchema
})
.merge(baseCollectorSchema);

const collectorSchema = z.discriminatedUnion("type", [mSchema, kSchema]);
it's this last schema that i can't call .pick on... because it's no longer a schema? it it because of this?
patrick
patrick15mo ago
GitHub
ZodObject methods (pick, omit, partial, etc.) for discriminatedUnio...
In issue #56 it was proposed to add ZodObject methods to intersections and unions. This was never implemented, because both z.intersection and z.union do not make any assumptions about the underlyi...
Nick
Nick15mo ago
I’m sure what you want to do can be done, you’ll just have to figure out some patterns which let you achieve it, probably a lot of inverted control plugged into the router factory I’ve done some similar stuff at work and it’s a pain to achieve, not really so easy to help with in a text chat