From 1182f71a22f0941a0f8e1ff8cbd193ea91f2af17 Mon Sep 17 00:00:00 2001 From: Nacy Dev Date: Sun, 1 Nov 2026 23:41:22 +0700 Subject: [PATCH] feat(types): allow string schema with separator for string[] properties --- types/index.d.ts | 177 ++++++++++++++++++++++++++++++++++++++++++++- types/index.tst.ts | 54 ++++++++++++++ 2 files changed, 228 insertions(+), 3 deletions(-) diff --git a/types/index.d.ts b/types/index.d.ts index 90cdcf4..9da97db 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,6 +1,177 @@ -import Ajv, { KeywordDefinition, JSONSchemaType } from 'ajv' +import Ajv, { KeywordDefinition, JSONSchemaType as AjvJSONSchemaType } from 'ajv' import { AnySchema } from 'ajv/dist/core' +type StrictNullChecksWrapper = undefined extends null + ? `strictNullChecks must be true in tsconfig to use ${Name}` + : Type +type UnionToIntersection = (U extends any ? (_: U) => void : never) extends (_: infer I) => void ? I : never +type UncheckedPartialSchema = Partial> +type JSONType = IsPartial extends true ? T | undefined : T + +interface NumberKeywords { + minimum?: number; + maximum?: number; + exclusiveMinimum?: number; + exclusiveMaximum?: number; + multipleOf?: number; + format?: string; +} + +interface StringKeywords { + minLength?: number; + maxLength?: number; + pattern?: string; + format?: string; +} + +type UncheckedJSONSchemaType = ( + | { + anyOf: readonly UncheckedJSONSchemaType[]; + } + | { + oneOf: readonly UncheckedJSONSchemaType[]; + } + | ({ + type: readonly (T extends number + ? JSONType<'number' | 'integer', IsPartial> + : T extends string + ? JSONType<'string', IsPartial> + : T extends boolean + ? JSONType<'boolean', IsPartial> + : never)[]; + } & UnionToIntersection< + T extends number ? NumberKeywords : T extends string ? StringKeywords : T extends boolean ? unknown : never + >) + | ((T extends number + ? { + type: JSONType<'number' | 'integer', IsPartial>; + } & NumberKeywords + : T extends string + ? { + type: JSONType<'string', IsPartial>; + } & StringKeywords + : T extends boolean + ? { + type: JSONType<'boolean', IsPartial>; + } + : T extends readonly [any, ...any[]] + ? { + type: JSONType<'array', IsPartial>; + items: { + readonly [K in keyof T]-?: UncheckedJSONSchemaType & Nullable; + } & { + length: T['length']; + }; + minItems: T['length']; + } & ({ maxItems: T['length'] } | { additionalItems: false }) + : T extends readonly any[] + ? { + type: JSONType<'array', IsPartial>; + items: UncheckedJSONSchemaType; + contains?: UncheckedPartialSchema; + minItems?: number; + maxItems?: number; + minContains?: number; + maxContains?: number; + uniqueItems?: true; + additionalItems?: never; + } + : T extends Record + ? { + type: JSONType<'object', IsPartial>; + additionalProperties?: boolean | UncheckedJSONSchemaType; + unevaluatedProperties?: boolean | UncheckedJSONSchemaType; + properties?: IsPartial extends true + ? Partial> + : UncheckedPropertiesSchema; + patternProperties?: Record>; + propertyNames?: Omit, 'type'> & { + type?: 'string'; + }; + dependencies?: { + [K in keyof T]?: readonly (keyof T)[] | UncheckedPartialSchema; + }; + dependentRequired?: { + [K in keyof T]?: readonly (keyof T)[]; + }; + dependentSchemas?: { + [K in keyof T]?: UncheckedPartialSchema; + }; + minProperties?: number; + maxProperties?: number; + } & (IsPartial extends true + ? { + required: readonly (keyof T)[]; + } + : [UncheckedRequiredMembers] extends [never] + ? { + required?: readonly UncheckedRequiredMembers[]; + } + : { + required: readonly UncheckedRequiredMembers[]; + }) + : T extends null + ? { + type: JSONType<'null', IsPartial>; + nullable: true; + } + : never) & { + allOf?: readonly UncheckedPartialSchema[]; + anyOf?: readonly UncheckedPartialSchema[]; + oneOf?: readonly UncheckedPartialSchema[]; + if?: UncheckedPartialSchema; + then?: UncheckedPartialSchema; + else?: UncheckedPartialSchema; + not?: UncheckedPartialSchema; + }) +) & { + [keyword: string]: any; + $id?: string; + $ref?: string; + $defs?: Record>; + definitions?: Record>; +} + +type Known = + | { [key: string]: Known } + | [Known, ...Known[]] + | Known[] + | number + | string + | boolean + | null + +type UncheckedPropertiesSchema = { + [K in keyof T]-?: + | (UncheckedJSONSchemaType & Nullable) + | (NonNullable extends readonly string[] + ? UncheckedJSONSchemaType & { separator: string } & Nullable< + undefined extends T[K] ? string | string[] | undefined : string | string[] + > + : never) + | { + $ref: string; + }; +} + +type UncheckedRequiredMembers = { + [K in keyof T]-?: undefined extends T[K] ? never : K; +}[keyof T] + +type Nullable = undefined extends T + ? { + nullable: true; + const?: null; + enum?: readonly (T | null)[]; + default?: T | null; + } + : { + nullable?: false; + const?: T; + enum?: readonly T[]; + default?: T; + } + /** * Options for loading .env files */ @@ -18,14 +189,14 @@ interface DotenvOptions { type EnvSchema = typeof envSchema declare namespace envSchema { - export type { JSONSchemaType } + export type JSONSchemaType = StrictNullChecksWrapper<'JSONSchemaType', UncheckedJSONSchemaType> export type EnvSchemaData = { [key: string]: unknown; } export type EnvSchemaOpt = { - schema?: JSONSchemaType | AnySchema; + schema?: JSONSchemaType | AjvJSONSchemaType | AnySchema; data?: [EnvSchemaData, ...EnvSchemaData[]] | EnvSchemaData; env?: boolean; dotenv?: boolean | DotenvOptions; diff --git a/types/index.tst.ts b/types/index.tst.ts index 2cc1ff7..4423fac 100644 --- a/types/index.tst.ts +++ b/types/index.tst.ts @@ -3,6 +3,7 @@ import envSchema, { EnvSchemaData, EnvSchemaOpt, keywords, + JSONSchemaType as EnvJSONSchemaType, envSchema as envSchemaNamed, default as envSchemaDefault, } from '.' @@ -115,3 +116,56 @@ expect(envSchemaTypebox).type.toBe() expect(keywords.separator).type.toBe() expect(envSchema.keywords.separator).type.toBe() + +interface EnvDataWithSeparator { + PORT: number; + ALLOWED_HOSTS: string[]; +} + +const schemaWithSeparator: EnvJSONSchemaType = { + type: 'object', + required: ['PORT', 'ALLOWED_HOSTS'], + properties: { + PORT: { + type: 'number', + default: 3000, + }, + ALLOWED_HOSTS: { + type: 'string', + separator: ',', + }, + }, +} + +const envSchemaWithSeparator = envSchema({ schema: schemaWithSeparator }) +expect(envSchemaWithSeparator).type.toBe() + +const schemaWithArray: EnvJSONSchemaType = { + type: 'object', + required: ['PORT', 'ALLOWED_HOSTS'], + properties: { + PORT: { + type: 'number', + }, + ALLOWED_HOSTS: { + type: 'array', + items: { + type: 'string', + }, + }, + }, +} +expect(envSchema({ schema: schemaWithArray })).type.toBe() + +expect>().type.not.toBeAssignableFrom({ + type: 'object', + required: ['PORT', 'ALLOWED_HOSTS'], + properties: { + PORT: { + type: 'number', + }, + ALLOWED_HOSTS: { + type: 'string', + }, + }, +})