json-schema-to-typescript icon indicating copy to clipboard operation
json-schema-to-typescript copied to clipboard

feat: custom schema extension

Open alpinagyok opened this issue 3 months ago • 0 comments

Overview

This PR adds support for parsing custom schema extension with parserExtensions option. This is not based on any concrete issue but just on my personal problem that I'm trying to solve. For context:

  • Somewhat related issue that I found https://github.com/bcherny/json-schema-to-typescript/issues/2
  • "Custom JSON-schema extensions" are mentioned in the README's features, which led me to assume this PR might be welcome

Problem

We use @sinclair/typebox + json-schema-to-typescript to share types between services and we have some non-standard schemas. Specifically, we commonly use typebox'es "Javascript constructs" like Type.Function.

Let's say I have this

import { Type } from '@sinclair/typebox';

const Test = Type.Function([Type.String()], Type.Number())

/*
which is just this JSON ⬇️
{
  type: 'Function',
  parameters: [
    {
      type: 'string',
    },
  ],
  returns: {
    type: 'number',
  },
}
*/

In this compile will just default to CUSTOM_TYPE which makes sense.

export interface Test {
  [k: string]: unknown;
}

Currently we hack around this issue with tsType like so

const Test = {
	...Type.Function([Type.String()], Type.Number()),
	tsType: '(someArg: string) => number',
}

which gets us the correct type.

export type Test = ((someArg: string) => number) => number;

While this, works you can already start to feel that we're doing some duplicated work which might be very error prone (Type.String and Type.Number are already handled by json-schema-to-typescript). Add one more level to the schema and tsType just becomes not worth it for us

const Test = Type.Function([
	Type.Object({
		id: Type.String(),
		name: Type.String(),
	})
], Type.Number());

/*
which is just this JSON ⬇️
{
  type: 'Function',
  parameters: [
    {
      type: 'object',
      properties: {
        id: {type: 'number'},
        name: {type: 'string'},
      },
      required: ['id'],
    },
  ],
  returns: {
    type: 'number',
  },
}
*/

Solution

Added parserExtensions option that allows defining a custom compile callback for unsupported type. When type matches, it runs the callback. Callback provides the current schema that's being parsed and compileSchema callback to basically pass the parsing back to the library.

Simple example

const Test = { type: 'myCustomString' };

compile(Test as any, 'test', {
  bannerComment: '',
  format: true,
  parserExtensions: {
    myCustomString: () => 'string',
  },
})

results in

export type Test = string;

Complicated example (with the mentioned Type.Function)

const Test = {
  type: 'Function',
  parameters: [
    {
      type: 'object',
      properties: {
        id: {type: 'number'},
        name: {type: 'string'},
      },
      required: ['id'],
    },
  ],
  returns: {
    type: 'number',
  },
}

compile(Test as any, 'test', {
  bannerComment: '',
  format: true,
  parserExtensions: {
    Function: (schema: any, compileSchema) => {
      const paramTypes = schema.parameters
        ? schema.parameters.map((param: any, index: number) => {
            const paramType = compileSchema(param)
            return `param${index}: ${paramType}`
          })
        : []

      const returnType = schema.returns ? compileSchema(schema.returns) : 'void'

      return `(${paramTypes.join(', ')}) => ${returnType}`
    },
  },
})

results in

export type Test = (param0: {id: number; name?: string; [k: string]: unknown}) => number;

Side Note

I'm well aware that I need to add tests. Willing to do that add and fix any concerns. Want to validate first if the idea is even welcome though 🙏, @bcherny.

alpinagyok avatar Sep 12 '25 13:09 alpinagyok