A
arktypeβ€’2w ago
Simon

Can the JSON Schema be typed automatically?

I'm trying to use the generated JSON Schema inside forms in order to provide useful information to users, such as if the field is required/optional, if there is a max length limit, etc.. I'm using this schema:
export const schema = type({
uid: type("string").configure({
message: () => i18n._("The episode UID is required."),
}),
title: type("string > 2").configure({
message: (ctx) =>
ctx.data
? i18n._("The episode title must be at least 3 characters long.")
: i18n._("The episode title is required."),
}),
summary: type("string <= 4000")
.configure({
message: () =>
i18n._("The summary must be less than 4000 characters long."),
})
.optional(),
});

const jsonSchema = schema.toJsonSchema()
export const schema = type({
uid: type("string").configure({
message: () => i18n._("The episode UID is required."),
}),
title: type("string > 2").configure({
message: (ctx) =>
ctx.data
? i18n._("The episode title must be at least 3 characters long.")
: i18n._("The episode title is required."),
}),
summary: type("string <= 4000")
.configure({
message: () =>
i18n._("The summary must be less than 4000 characters long."),
})
.optional(),
});

const jsonSchema = schema.toJsonSchema()
The JSON Schema I get, is a JsonSchema.NonBooleanBranch which is too broad and I can't really use it in TS without a lot of defensive checks and casting. Ideally I'd like to use it like this:
<form.AppField
name="summary"
children={(field) => (
<field.FormItem className="grid gap-2">
<field.FormLabel>
<Trans>Summary</Trans>
{schema.required?.includes(field.name) && (
<span className="italic">
β€” <Trans>optional</Trans>
</span>
)}
</field.FormLabel>
<field.FormControl>
<field.FormTextarea />
</field.FormControl>
<div className="italic text-xs text-right">
<Trans>
{{ count: field.state.value?.length ?? 0 }} /{" "}
{{
max: schema.properties[field.name]!.maxLength! ?? 0,
}}{" "}
characters
</Trans>
</div>
<field.FormMessage />
</field.FormItem>
)}
/>
<form.AppField
name="summary"
children={(field) => (
<field.FormItem className="grid gap-2">
<field.FormLabel>
<Trans>Summary</Trans>
{schema.required?.includes(field.name) && (
<span className="italic">
β€” <Trans>optional</Trans>
</span>
)}
</field.FormLabel>
<field.FormControl>
<field.FormTextarea />
</field.FormControl>
<div className="italic text-xs text-right">
<Trans>
{{ count: field.state.value?.length ?? 0 }} /{" "}
{{
max: schema.properties[field.name]!.maxLength! ?? 0,
}}{" "}
characters
</Trans>
</div>
<field.FormMessage />
</field.FormItem>
)}
/>
30 Replies
Simon
SimonOPβ€’2w ago
This works, but with TS errors such as :
Property 'required' does not exist on type 'NonBooleanBranch'.
Property 'required' does not exist on type 'Constrainable'.ts(2339)
Property 'properties' does not exist on type 'NonBooleanBranch'.
Property 'properties' does not exist on type 'Constrainable'.ts(2339
Property 'required' does not exist on type 'NonBooleanBranch'.
Property 'required' does not exist on type 'Constrainable'.ts(2339)
Property 'properties' does not exist on type 'NonBooleanBranch'.
Property 'properties' does not exist on type 'Constrainable'.ts(2339
TizzySaurus
TizzySaurusβ€’2w ago
You can't really get more narrow types by default. You just need to narrow the type down by checking property access E.g. "type" in schema && schema.type === "number" // narrows to a JSON Schema for a number or w/e
Simon
SimonOPβ€’2w ago
That's a bummer. Is there any know libs that do the heavy lifting for us?
TizzySaurus
TizzySaurusβ€’2w ago
Why can't you just narrow? It's should be simple enough 🀷
TizzySaurus
TizzySaurusβ€’2w ago
And if you need to re-use it then create a custom type guard/predicate: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates
Documentation - Narrowing
Understand how TypeScript uses JavaScript knowledge to reduce the amount of type syntax in your projects.
Simon
SimonOPβ€’2w ago
It feels kind of counterproductive. My goal is to reuse the schema to get the values like required/maxLength. If I check property access it feels like a rabbit hole. My first tests were unconclusive. Here's a sample:
if (schema.type === "object" && schema.properties?.[field.name] && schema.properties[field.name]?.type === "string") {
max = schema.properties[field.name].maxLength
}
if (schema.type === "object" && schema.properties?.[field.name] && schema.properties[field.name]?.type === "string") {
max = schema.properties[field.name].maxLength
}
In this instance, type does not exist in schema.properties[field.name]
Property 'type' does not exist on type 'NonBooleanBranch'. Property 'type' does not exist on type 'Const'.ts(2339)
here's my schema:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"title": {
"type": "string",
"message": "$ark.message1",
"minLength": 3
},
"uid": {
"type": "string",
"message": "$ark.message"
},
"summary": {
"type": "string",
"message": "$ark.message2",
"maxLength": 3999
}
},
"required": [
"title",
"uid"
]
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"title": {
"type": "string",
"message": "$ark.message1",
"minLength": 3
},
"uid": {
"type": "string",
"message": "$ark.message"
},
"summary": {
"type": "string",
"message": "$ark.message2",
"maxLength": 3999
}
},
"required": [
"title",
"uid"
]
}
TizzySaurus
TizzySaurusβ€’2w ago
Why exactly are you doing these operations on the JSON Schema and not the ArkType type itself?
Simon
SimonOPβ€’2w ago
Not sure how I could use the type itself πŸ€” I don't want to validate, I want to get the validation rules if that matters
TizzySaurus
TizzySaurusβ€’2w ago
I'm not super familiar with this part of the api, but iirc there's stuff like type({foo: "string>3"}).get("foo")
Simon
SimonOPβ€’2w ago
Indeed, is there a way to get the "maxLength" from that?
TizzySaurus
TizzySaurusβ€’2w ago
ArkType
ArkType Docs
TypeScript's 1:1 validator, optimized from editor to runtime
TizzySaurus
TizzySaurusβ€’2w ago
I guess try it and see You can also traverse the typeInstance.json which I know definitely has the maxLength (possibly under a different key name) but I'm not sure how stable that's considered -- I think it's considered stable enough though, as of 2.0 Yeah, .get just returns the equivalent typeInstance for that key So I think the solution is to use typeInstance.json/typeInstance.get("key").json @Simon
Simon
SimonOPβ€’2w ago
Ah yes, that looks promising
TizzySaurus
TizzySaurusβ€’2w ago
Having implemented the source code for parsing JSON Schema into ArkType schema I'm pretty familiar with ArkType's internal schema system (returned by .json), so if you have any specific queries on it feel free to ping me (afaik it's not documented anywhere)
Simon
SimonOPβ€’2w ago
Looks like it returns either an object or an array of objects depending on the number of rules I suppose or the kind of rules
// title property
{
"domain": {
"domain": "string",
"meta": {
"message": "$ark.message1"
}
},
"minLength": {
"rule": 3,
"meta": {
"message": "$ark.message1"
}
},
"meta": {
"message": "$ark.message1"
}
}
// title property
{
"domain": {
"domain": "string",
"meta": {
"message": "$ark.message1"
}
},
"minLength": {
"rule": 3,
"meta": {
"message": "$ark.message1"
}
},
"meta": {
"message": "$ark.message1"
}
}
// summary property
[
{
"domain": {
"domain": "string",
"meta": {
"message": "$ark.message2"
}
},
"maxLength": {
"rule": 4000,
"meta": {
"message": "$ark.message2"
}
},
"meta": {
"message": "$ark.message2"
}
},
{
"unit": "undefined"
}
]
// summary property
[
{
"domain": {
"domain": "string",
"meta": {
"message": "$ark.message2"
}
},
"maxLength": {
"rule": 4000,
"meta": {
"message": "$ark.message2"
}
},
"meta": {
"message": "$ark.message2"
}
},
{
"unit": "undefined"
}
]
TizzySaurus
TizzySaurusβ€’2w ago
Iirc an array represents a union Which seems to match what you've got there I.e. the "summary" property is either "a string with maxLength 4000", or undefined. Which I imagine you can confirm by looking at the .description instead of the .json ( @Simon )
TizzySaurus
TizzySaurusβ€’2w ago
Fwiw you can get an idea of what some of the different ArkType schemas look like by browing the ark/jsonschema/__tests__ on this commit (at least for string/number/array/object): https://github.com/TizzySaurus/arktype/blob/f6268f7727bf0c51b133d3a24b08cc1bf025094f/ark/jsonschema/__tests__/string.test.ts
GitHub
arktype/ark/jsonschema/tests/string.test.ts at f6268f7727bf0c51...
TypeScript's 1:1 validator, optimized from editor to runtime - TizzySaurus/arktype
Simon
SimonOPβ€’2w ago
Got to something with some checks.
const summaryProperty = schema.get("summary").json;
const properties = Array.isArray(summaryProperty)
? summaryProperty
: [summaryProperty];
const max = properties.reduce((max, property) => {
if (property === null) return max;
if (typeof property === "string") return max;
if (typeof property === "number") return max;
if (typeof property === "boolean") return max;
if (Array.isArray(property)) return max;

console.log("property", property, Object.keys(property));
if (Object.keys(property).includes("maxLength") && property.maxLength) {
console.log("maxLength", property.maxLength);
const maxLength = property.maxLength;

if (maxLength === null) return max;
if (typeof maxLength === "string") return max;
if (typeof maxLength === "number") return max;
if (typeof maxLength === "boolean") return max;
if (Array.isArray(maxLength)) return max;

return maxLength.rule;
} else {
console.log("not maxLength", property.maxLength);
}

return max;
}, 0);
const summaryProperty = schema.get("summary").json;
const properties = Array.isArray(summaryProperty)
? summaryProperty
: [summaryProperty];
const max = properties.reduce((max, property) => {
if (property === null) return max;
if (typeof property === "string") return max;
if (typeof property === "number") return max;
if (typeof property === "boolean") return max;
if (Array.isArray(property)) return max;

console.log("property", property, Object.keys(property));
if (Object.keys(property).includes("maxLength") && property.maxLength) {
console.log("maxLength", property.maxLength);
const maxLength = property.maxLength;

if (maxLength === null) return max;
if (typeof maxLength === "string") return max;
if (typeof maxLength === "number") return max;
if (typeof maxLength === "boolean") return max;
if (Array.isArray(maxLength)) return max;

return maxLength.rule;
} else {
console.log("not maxLength", property.maxLength);
}

return max;
}, 0);
I suppose I can make helpers and use those as I won't have that many different cases
TizzySaurus
TizzySaurusβ€’2w ago
Fwiw (Object.keys(property).includes("maxLength") can be simplified to "maxLength in property I'd also be inclined to merge the return max if statements since they all do the same thing And since it's repeated, maybe it's worth moving it to a util function Although it's not really clear what those if statements actually add
Simon
SimonOPβ€’2w ago
bummer that I can't seem to narrow to the JsonObject instead of dismissing string, boolean, number etc..
TizzySaurus
TizzySaurusβ€’2w ago
As I said originally, just do "type" in jsonSchema && jsonSchema.type === "object" (in theory, anyway)
Simon
SimonOPβ€’2w ago
Ah yeah, also need to check if not null Thanks for guiding me through this πŸ™‚ I think I have what I need to go further now
ssalbdivad
ssalbdivadβ€’7d ago
@Simon We're about to release docs for the select method which will really help with the kind of introspection you're describing directly in ArkType. Here's a snippet from those (the API is already available in the current version): select is the top-level first method we're introducing for interacting with a Type based on its internal representation. It can be used to filter a Type's references:
const T = type({
name: "string > 5",
flag: "0 | 1"
})
.array()
.atLeastLength(1)

// get all references representing literal values
const literals = T.select("unit") // [Type<0>, Type<1>]

// get all references representing literal positive numbers
const positiveNumberLiterals = T.select({
kind: "unit",
where: u => typeof u.unit === "number" && u.unit > 0
}) // [Type<1>]

// get all minLength constraints at the root of the Type
const minLengthConstraints = T.select({
kind: "minLength",
// the shallow filter excludes the constraint on `name`
boundary: "shallow"
}) // [MinLengthNode<1>]
const T = type({
name: "string > 5",
flag: "0 | 1"
})
.array()
.atLeastLength(1)

// get all references representing literal values
const literals = T.select("unit") // [Type<0>, Type<1>]

// get all references representing literal positive numbers
const positiveNumberLiterals = T.select({
kind: "unit",
where: u => typeof u.unit === "number" && u.unit > 0
}) // [Type<1>]

// get all minLength constraints at the root of the Type
const minLengthConstraints = T.select({
kind: "minLength",
// the shallow filter excludes the constraint on `name`
boundary: "shallow"
}) // [MinLengthNode<1>]
When you have a constraint node like MinLength, you can access .rule to get its value (plus all sorts of other useful properties/methods)
Simon
SimonOPβ€’7d ago
Very interesting. That’d make it really more easy to deal with! Just tested and that's awesome. I could quickly create a few helpers that are easily typed & reused
import type { Type } from "arktype";

/**
* Check if the field is optional
*/
export function isOptional(fieldSchema: Type) {
const requiredConstraints = fieldSchema.select({
kind: "required",
});

return requiredConstraints.length === 0;
}

/**
* Retrieve the maxLength rule from the schema
*/
export function getMaxLength(fieldSchema: Type) {
const maxLengthConstraints = fieldSchema.select({
kind: "maxLength",
boundary: "shallow",
});

if (maxLengthConstraints.length === 0) {
return null;
}

return maxLengthConstraints.reduce((max, constraint) => {
if (constraint.rule > max) {
return constraint.rule;
}

return max;
}, 0);
}
import type { Type } from "arktype";

/**
* Check if the field is optional
*/
export function isOptional(fieldSchema: Type) {
const requiredConstraints = fieldSchema.select({
kind: "required",
});

return requiredConstraints.length === 0;
}

/**
* Retrieve the maxLength rule from the schema
*/
export function getMaxLength(fieldSchema: Type) {
const maxLengthConstraints = fieldSchema.select({
kind: "maxLength",
boundary: "shallow",
});

if (maxLengthConstraints.length === 0) {
return null;
}

return maxLengthConstraints.reduce((max, constraint) => {
if (constraint.rule > max) {
return constraint.rule;
}

return max;
}, 0);
}
const fieldSchema = schema.get(field.name);
const maxLength = getMaxLength(fieldSchema);
const fieldSchema = schema.get(field.name);
const maxLength = getMaxLength(fieldSchema);
ssalbdivad
ssalbdivadβ€’7d ago
Looks great! You'd only have multiple shallow max length fields in the case of a union, so in that case you'd want to return the least strict one?
Simon
SimonOPβ€’7d ago
Makes sens yeah @ArkDavid is the .select function costly? I wonder if I can run a few of those for each field and automatically add the components (like "β€” optional" or "0 / 4000 characters") if the constraints exists or if there will be a heavy price for large forms Looks like my isOptional is not stable for all schemas. Is something like this supposed to be the way?
export function isOptional(fieldSchema: Type) {
const optionalConstraints = fieldSchema.select({
boundary: "shallow",
kind: "unit",
});

return (
optionalConstraints.filter((constraint) => constraint.optional).length > 0
);
}
export function isOptional(fieldSchema: Type) {
const optionalConstraints = fieldSchema.select({
boundary: "shallow",
kind: "unit",
});

return (
optionalConstraints.filter((constraint) => constraint.optional).length > 0
);
}
ssalbdivad
ssalbdivadβ€’7d ago
It is cheap it is just iterating over an existing list of references and filtering them if you're looking for optional properties you should just filter by kind: "optional"
Simon
SimonOPβ€’6d ago
I'm having some weird results with the select but maybe I'm misunderstanding how it works. Given the following schema:
const Thing = type({
uid: type("string"),
"summary": type("string <= 4000 | undefined").optional(),
"description?": type("string <= 4000 | undefined"),
})
const Thing = type({
uid: type("string"),
"summary": type("string <= 4000 | undefined").optional(),
"description?": type("string <= 4000 | undefined"),
})
const out = Thing.select({ kind: "optional" }).map(c => c.toString())
const out = Thing.select({ kind: "optional" }).map(c => c.toString())
Prints:
[
"Type<description?: string <= 4000 | undefined>",
"Type<summary?: string <= 4000 | undefined>"
]
[
"Type<description?: string <= 4000 | undefined>",
"Type<summary?: string <= 4000 | undefined>"
]
Which is expected.
const out = Thing.get('summary').select({ kind: "optional" }).map(c => c.toString())
const out = Thing.get('summary').select({ kind: "optional" }).map(c => c.toString())
This however returns an empty array.
ssalbdivad
ssalbdivadβ€’6d ago
Optionality belongs to the key, not the value. Once you get the value, you don't have the optional node anymore You want to select optional nodes then you can map those to c => c.value if you want the associated value
Simon
SimonOPβ€’6d ago
Oh I missed that, makes sense

Did you find this page helpful?