Stop typing twice 🙅♂️
A lot of projects use JSON schemas for runtime data validation along with TypeScript for static type checking.
Their code may look like this:
const dogSchema = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "integer" },
hobbies: { type: "array", items: { type: "string" } },
favoriteFood: { enum: ["pizza", "taco", "fries"] },
},
required: ["name", "age"],
};
type Dog = {
name: string;
age: number;
hobbies?: string[];
favoriteFood?: "pizza" | "taco" | "fries";
};
Both objects carry similar if not exactly the same information. This is a code duplication that can annoy developers and introduce bugs if not properly maintained.
That's when json-schema-to-ts
comes to the rescue 💪
FromSchema
The FromSchema
method lets you infer TS types directly from JSON schemas:
import { FromSchema } from "json-schema-to-ts";
const dogSchema = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "integer" },
hobbies: { type: "array", items: { type: "string" } },
favoriteFood: { enum: ["pizza", "taco", "fries"] },
},
required: ["name", "age"],
} as const;
type Dog = FromSchema<typeof dogSchema>;
Schemas can even be nested, as long as you don't forget the as const
statement:
const catSchema = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "integer" },
favoriteThings: { enum: ["playing", "sleeping", "moreSleeping"] },
},
required: ["name", "age"],
} as const;
const petSchema = {
anyOf: [dogSchema, catSchema],
} as const;
type Pet = FromSchema<typeof petSchema>;
The as const
statement is used so that TypeScript takes the schema definition to the word (e.g. true is interpreted as the true constant and not widened as boolean). It is pure TypeScript and has zero impact on the compiled code.
Docs
Installation
npm install --save-dev json-schema-to-ts
yarn add --dev json-schema-to-ts
Use cases
Const
const fooSchema = {
const: "foo",
} as const;
type Foo = FromSchema<typeof fooSchema>;
Enums
const enumSchema = {
enum: [true, 42, { foo: "bar" }],
} as const;
type Enum = FromSchema<typeof enumSchema>;
You can also go full circle with typescript enums
.
enum Food {
Pizza = "pizza",
Taco = "taco",
Fries = "Fries",
}
const enumSchema = {
enum: Object.values(Food),
} as const;
type Enum = FromSchema<typeof enumSchema>;
Litterals
const litteralSchema = {
type: "null",
} as const;
type Litteral = FromSchema<typeof litteralSchema>;
const litteralsSchema = {
type: ["null", "string"],
} as const;
type Litterals = FromSchema<typeof litteralsSchema>;
For object
and array
types, properties like required
or additionalItems
will apply 🙌
Arrays
const arraySchema = {
type: "array",
items: { type: "string" },
} as const;
type Array = FromSchema<typeof arraySchema>;
Tuples
const tupleSchema = {
type: "array",
items: [{ type: "boolean" }, { type: "string" }],
} as const;
type Tuple = FromSchema<typeof tupleSchema>;
FromSchema
supports the additionalItems
specifications:
const tupleSchema = {
type: "array",
items: [{ type: "boolean" }, { type: "string" }],
additionalItems: false,
} as const;
type Tuple = FromSchema<typeof tupleSchema>;
const tupleSchema = {
type: "array",
items: [{ type: "boolean" }, { type: "string" }],
additionalItems: { type: "number" },
} as const;
type Tuple = FromSchema<typeof tupleSchema>;
...as well as the minItems
and maxItems
specifications:
const tupleSchema = {
type: "array",
items: [{ type: "boolean" }, { type: "string" }],
minItems: 1,
maxItems: 2,
} as const;
type Tuple = FromSchema<typeof tupleSchema>;
Additional items will only work if Typescript's strictNullChecks
option is activated
Objects
const objectSchema = {
type: "object",
properties: {
foo: { type: "string" },
bar: { type: "number" },
},
required: ["foo"],
} as const;
type Object = FromSchema<typeof objectSchema>;
FromSchema
partially supports the additionalProperties
and patternProperties
specifications:
additionalProperties
can be used to deny additional items.
const closedObjectSchema = {
...objectSchema,
additionalProperties: false,
} as const;
type Object = FromSchema<typeof additionalPropertiesSchema>;
- Used on their own,
additionalProperties
and/or patternProperties
can be used to type unnamed properties.
const objectSchema = {
type: "object",
additionalProperties: {
type: "boolean",
},
patternProperties: {
"^S": { type: "string" },
"^I": { type: "integer" },
},
} as const;
type Object = FromSchema<typeof typedValuesSchema>;
- However, when used in combination with the
properties
keyword, extra properties will always be typed as unknown
to avoid conflicts.
AnyOf
const anyOfSchema = {
anyOf: [
{ type: "string" },
{
type: "array",
items: { type: "string" },
},
],
} as const;
type AnyOf = FromSchema<typeof fooSchema>;
FromSchema
will correctly infer factored schemas:
const factoredSchema = {
type: "object",
properties: {
bool: { type: "boolean" },
},
required: ["bool"],
anyOf: [
{
properties: {
str: { type: "string" },
},
required: ["str"],
},
{
properties: {
num: { type: "number" },
},
},
],
} as const;
type Factored = FromSchema<typeof factoredSchema>;
OneOf
Because TypeScript misses refinment types, FromSchema
will use the oneOf
keyword in the same way as anyOf
:
const catSchema = {
type: "object",
oneOf: [
{
properties: {
name: { type: "string" },
},
required: ["name"],
},
{
properties: {
color: { enum: ["black", "brown", "white"] },
},
},
],
} as const;
type Cat = FromSchema<typeof catSchema>;
const invalidCat: Cat = { name: "Garfield" };
AllOf
const addressSchema = {
type: "object",
allOf: [
{
properties: {
address: { type: "string" },
city: { type: "string" },
state: { type: "string" },
},
required: ["address", "city", "state"],
},
{
properties: {
type: { enum: ["residential", "business"] },
},
},
],
} as const;
type Address = FromSchema<typeof addressSchema>;
Not & If/Then/Else
For the same reason as oneOf
(missing refinment types), I feel like implementing the not
and the if/then/else
keywords in FromSchema
would lead into a rabbit hole...
But I may be wrong ! If you have a use case and you think that it can be implemented, feel free to submit a PR 🤗