SPFx 1.24 Beta 2: Better Copilot Inputs with Zod Enums

SPFx 1.24 Beta 2: Better Copilot Inputs with Zod Enums

In my previous post I showed the bigger picture around my first SharePoint Copilot App. This time I want to zoom into one small detail that turned out to be worth improving.

I am not perfect. My usual way of learning new things is to play with them until I understand how they work, where they break, and how I can improve them.

That is exactly what happened with one of the input properties in my Copilot component.

In the beginning, I had this in the schema:

taskFilter: z.string().optional().describe('Optional filter for task category.')

It worked. But it also meant the property was basically free text. And if you know me, you also know that typing things exactly right every single time is not always my strongest skill.

So after some testing, I started looking at how I could make the input more predictable.

Why Free Text Was Fine First

For a first prototype, z.string().optional() is completely okay.

It is fast. It is flexible. And it lets you move forward without thinking too much about the contract up front.

But once I started using the property more seriously, the weak points became obvious.

  • The input had no guardrails
  • Typos could slip through
  • The UI logic had to deal with unexpected values
  • The valid filter values were already known anyway

That was the point where free text stopped being helpful and started being noise.

The Better Fit: An Enum

The improvement was simple. I replaced the optional string with an enum.

const taskFilterValues = ['All', 'Open', 'InProgress', 'Completed'] as const;

const propertiesSchema = z.object({
  message: z.string().describe('A message to display.'),
  taskFilter: z
    .enum(taskFilterValues)
    .optional()
    .describe('Filter for task category.')
});

This is one of those changes that looks tiny in code, but improves the contract immediately.

Now the schema says exactly which values are valid. No guessing. No spelling drift. No strange values sneaking into the component.

What This Improves in Practice

The main win is that the allowed values are enforced at schema validation time.

That means:

  • Only valid values pass validation
  • The contract is easier to understand later
  • The schema documents intent much better than a plain string
  • Downstream code can rely on a smaller and safer set of states

For me, this is also a build-time design improvement. The value set is defined up front, and the generated schema reflects that directly.

Other Options I Looked At

While experimenting, I also looked at a few other patterns.

Enum with strict predefined values

This is the option I ended up using.

It works best when the valid values are small and already known:

  • All
  • Open
  • InProgress
  • Completed

If the input space is already clear, this is the cleanest option.

Native TypeScript enum

You can define a TypeScript enum once and reuse it across your schema and application code.

That is useful when the same values need to be shared across multiple files.

Union of literals

This is similar to an enum, but more explicit.

It is useful when you want a fixed set of values plus one special case.

Optional enum with a default

If you want the property to stay optional but still fall back to a known state, a default like All can make sense.

That is often a nice UX choice when no filter is provided.

Free text with validation rules

If flexibility matters more than strictness, you can keep z.string() and add rules such as:

  • min
  • max
  • regex
  • refine

That still gives you open input, but with at least some boundaries.

Hybrid: predefined or custom

There is also a middle ground.

You can allow known values and still permit arbitrary strings. That works if you want suggestions without turning them into a hard restriction.

In my case, that would have been the wrong tradeoff. The filter values are known, so I wanted the schema to say exactly that.

Nested object vs flat properties

I also tried a slightly richer version where the filter was not a single value anymore, but a small object.

const taskFilterSchema = z.object({
  category: z.enum(taskCategoryValues).optional().describe('Category filter for tasks.'),
  titleSearch: z.string().min(1).optional().describe('Filter tasks by title text.'),
  pointsHigherThan: z.number().int().nonnegative().optional().describe('Only include tasks with points higher than this value.')
});

const propertiesSchema = z.object({
  message: z.string().describe('A message to display.'),
  taskFilter: taskFilterSchema.optional().describe('Optional structured filter for task data.')
});

And then I tried the flatter version:

const propertiesSchemaFlat = z.object({
  message: z.string().describe('A message to display.'),
  category: z.enum(taskCategoryValues).optional().describe('Category filter for tasks.'),
  titleSearch: z.string().min(1).optional().describe('Filter tasks by title text.'),
  pointsHigherThan: z.number().int().nonnegative().optional().describe('Only include tasks with points higher than this value.')
});

From a Zod point of view, both are fine. The nested version groups things nicely. The flat version makes the contract a bit more direct because the model does not need to build one more object before it can fill the values.

In my tests, the flat shape felt a bit more reliable. Not because the schema was more correct, but because the property path was simpler.

There was one limitation I could not work around cleanly. The values for z.enum(...) need to exist when the schema is built. So if you want to populate category values asynchronously, this pattern is not a great fit.

For my scenario, that was acceptable. The allowed values were known at build time anyway. But if your valid choices only show up later, then a strict enum becomes harder to justify.

When I Would Still Keep a String

An enum is not always the right answer.

If you really want free text plus known values, I would keep z.string() and validate later in the component code.

And if you want no filter to be a real explicit state, you have two clean options:

  • Keep the property optional
  • Add a literal value like All

Both are valid. It just depends on whether absence and show-everything should mean the same thing in your app.

Why I Like This in SPFx 1.24 Beta 2

What I like here is not just the enum itself.

I like that the Copilot tool contract becomes clearer while still staying simple. SPFx 1.24 Beta 2 already gives us a good path from Zod schema to generated schema metadata, and this kind of refinement makes that path more useful.

Instead of treating the property definition as a formality, it becomes part of the app design.

And honestly, that is usually how better solutions show up for me. Not because I got everything right on the first try, but because I played with it long enough to notice where it deserved a better shape.

Takeaway

If your Copilot property already has a small, known set of valid values, do not keep it as free text longer than necessary.

A Zod enum gives you a tighter contract, better validation, and less room for silly mistakes. In my case, it was a very small change, but a very useful one.

And yes, this solution is currently defined during build time. For this scenario, that is exactly what I want.