Skip to content

Commit 6056342

Browse files
committed
feat: improve TypeScript autocompletion
1 parent fe488f4 commit 6056342

File tree

5 files changed

+251
-387
lines changed

5 files changed

+251
-387
lines changed

src/Schema.ts

Lines changed: 87 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -10,35 +10,47 @@ import ValidationError, { FieldErrors } from './errors/ValidationError'
1010
import SchemaField, { FieldProperties } from './SchemaField'
1111
import FieldResolutionError from './errors/FieldResolutionError'
1212

13-
// todo allow type inference (autocomplete schema fields from fields definition)
14-
interface FieldsDefinition {
15-
[key: string]: FieldProperties<unknown>;
13+
type Fields<K extends string | number | symbol = string> = Record<K, FieldProperties>
14+
15+
type SchemaFields<F extends Fields> = { [K in keyof F]: SchemaField<F[K]> }
16+
17+
/**
18+
* Makes fields partial. (required: false)
19+
*/
20+
export type PartialFields<F extends Fields> = {
21+
[K in keyof F]: Omit<F[K], 'required'> & { required: false }
22+
}
23+
24+
/**
25+
* Makes fields mandatory. (required: true)
26+
*/
27+
export type RequiredFields<F extends Fields> = {
28+
[K in keyof F]: Omit<F[K], 'required'> & { required: true }
1629
}
1730

1831
interface ValidateOptions {
1932
clean?: boolean;
20-
context?: Record<string, unknown>,
33+
context?: Record<string, unknown>;
2134
ignoreMissing?: boolean;
2235
ignoreUnknown?: boolean;
2336
parse?: boolean;
2437
path?: string;
2538
removeUnknown?: boolean;
2639
}
2740

28-
class Schema {
29-
public fields: { [key: string]: SchemaField<unknown> }
30-
31-
constructor (fields: FieldsDefinition) {
32-
this.fields = {}
41+
class Schema<F extends Fields = Fields> {
42+
public fields: SchemaFields<F> = {} as any
3343

44+
constructor (fields: F) {
3445
// Set fields.
35-
Object.keys(fields).forEach((name: string): void => {
36-
this.fields[name] = new SchemaField(name, fields[name])
46+
Object.keys(fields).forEach((name: keyof F): void => {
47+
this.fields[name] = new SchemaField(String(name), fields[name])
3748
})
3849
}
3950

4051
/**
4152
* Returns a clean copy of the object.
53+
* todo move to util functions
4254
* @param object
4355
* @param options
4456
*/
@@ -66,21 +78,26 @@ class Schema {
6678
/**
6779
* Returns a clone of the schema.
6880
*/
69-
clone (): Schema {
70-
return this.pick(Object.keys(this.fields))
81+
clone (): Schema<F> {
82+
const fields: Fields = {}
83+
84+
Object.keys(this.fields).forEach((name) => {
85+
fields[name] = this.fields[name].getProperties()
86+
})
87+
return new Schema(deepExtend({}, fields))
7188
}
7289

7390
/**
7491
* Returns a new schema based on current schema.
75-
* @param fields
92+
* @param newFields
7693
*/
77-
extend (fields: FieldsDefinition): Schema {
78-
const fieldsDefinition: FieldsDefinition = {}
94+
extend<NF extends Fields> (newFields: NF): Schema<F & NF> {
95+
const fields: Fields = {}
7996

80-
Object.keys(this.fields).forEach((name: string): void => {
81-
fieldsDefinition[name] = this.fields[name].getProperties()
97+
Object.keys(this.fields).forEach((name) => {
98+
fields[name] = this.fields[name].getProperties()
8299
})
83-
return new Schema(deepExtend({}, fieldsDefinition, fields))
100+
return new Schema(deepExtend({}, fields, newFields))
84101
}
85102

86103
/**
@@ -120,14 +137,14 @@ class Schema {
120137
* Returns a field.
121138
* @param name
122139
*/
123-
getField (name: string): SchemaField<unknown> {
124-
return this.resolveField(name)
140+
getField<N extends keyof F> (name: N): SchemaFields<F>[N] {
141+
return this.fields[name]
125142
}
126143

127144
/**
128145
* Returns all fields.
129146
*/
130-
getFields (): { [key: string]: SchemaField<unknown> } {
147+
getFields (): SchemaFields<F> {
131148
return this.fields
132149
}
133150

@@ -149,11 +166,11 @@ class Schema {
149166
* Returns a sub schema without some fields.
150167
* @param fieldNames
151168
*/
152-
omit (fieldNames: string[]): Schema {
153-
const fields: FieldsDefinition = {}
169+
omit<K extends keyof F> (fieldNames: K[]): Schema<Omit<F, K>> {
170+
const fields: Fields = {}
154171

155-
Object.keys(this.fields).forEach((name: string): void => {
156-
if (fieldNames.indexOf(name) === -1) {
172+
Object.keys(this.fields).forEach((name) => {
173+
if (!fieldNames.includes(name as K)) {
157174
fields[name] = this.fields[name].getProperties()
158175
}
159176
})
@@ -178,39 +195,26 @@ class Schema {
178195
/**
179196
* Returns a copy of the schema where all fields are not required.
180197
*/
181-
partial () {
182-
const fields: FieldsDefinition = {}
198+
partial (): Schema<PartialFields<F>> {
199+
const fields: Fields = {}
183200

184-
Object.keys(this.fields).forEach((name: string): void => {
201+
Object.keys(this.fields).forEach((name) => {
185202
fields[name] = deepExtend({}, this.fields[name].getProperties())
186203
fields[name].required = false
187204
})
188205
return new Schema(deepExtend({}, fields))
189206
}
190207

191-
/**
192-
* Returns a copy of the schema where all fields are required.
193-
*/
194-
required () {
195-
const fields: FieldsDefinition = {}
196-
197-
Object.keys(this.fields).forEach((name: string): void => {
198-
fields[name] = deepExtend({}, this.fields[name].getProperties())
199-
fields[name].required = true
200-
})
201-
return new Schema(deepExtend({}, fields))
202-
}
203-
204208
/**
205209
* Returns a sub schema from selected fields.
206210
* @param fieldNames
207211
*/
208-
pick (fieldNames: string[]): Schema {
209-
const fields: FieldsDefinition = {}
212+
pick<K extends keyof F> (fieldNames: K[]): Schema<Pick<F, K>> {
213+
const fields: Fields = {}
210214

211-
fieldNames.forEach((name: string): void => {
215+
fieldNames.forEach((name) => {
212216
if (typeof this.fields[name] !== 'undefined') {
213-
fields[name] = this.fields[name].getProperties()
217+
fields[String(name)] = this.fields[name].getProperties()
214218
}
215219
})
216220
return new Schema(deepExtend({}, fields))
@@ -220,39 +224,52 @@ class Schema {
220224
* Returns a copy of the object without unknown fields.
221225
* @param object
222226
*/
223-
removeUnknownFields<T> (object: Record<string, unknown>): T {
227+
removeUnknownFields (object: Record<string, unknown>): Schema<F> {
224228
if (object == null) {
225229
return object
226230
}
227231
const clone = deepExtend({}, object)
228232

229233
Object.keys(clone).forEach((name: string): void => {
230-
const field: SchemaField<unknown> = this.fields[name]
234+
const field = this.fields[name]
231235

232236
if (typeof field === 'undefined') {
233237
delete clone[name]
234238
} else if (field.getType() instanceof Schema) {
235239
clone[name] = (field.getType() as Schema).removeUnknownFields(clone[name])
236240
} else if (field.getItems()?.type instanceof Schema) {
237241
if (clone[name] instanceof Array) {
238-
clone[name] = clone[name].map((item: any) => (
239-
(field.getItems().type as Schema).removeUnknownFields(item)
242+
clone[name] = clone[name].map((item) => (
243+
(field.getItems()?.type as Schema).removeUnknownFields(item)
240244
))
241245
}
242246
}
243247
})
244248
return clone
245249
}
246250

251+
/**
252+
* Returns a copy of the schema where all fields are required.
253+
*/
254+
required (): Schema<RequiredFields<F>> {
255+
const fields = {} as Fields
256+
257+
Object.keys(this.fields).forEach((name: string): void => {
258+
fields[name] = deepExtend({}, this.fields[name].getProperties())
259+
fields[name].required = true
260+
})
261+
return new Schema(deepExtend({}, fields))
262+
}
263+
247264
/**
248265
* Builds an object from a string (ex: [colors][0][code]).
249266
* @param path (ex: address[country][code])
250267
* @param syntaxChecked
251-
* @throws {SyntaxError|TypeError}
252268
*/
253-
resolveField (path: string, syntaxChecked = false): SchemaField<unknown> {
269+
resolveField<T extends SchemaField<FieldProperties>> (path: keyof F | string, syntaxChecked = false): T {
270+
const p = path.toString()
254271
// Removes array indexes from path because we want to resolve field and not data.
255-
const realPath = path.replace(/\[\d+]/g, '')
272+
const realPath = p.replace(/\[\d+]/g, '')
256273

257274
const bracketIndex = realPath.indexOf('[')
258275
const bracketEnd = realPath.indexOf(']')
@@ -262,31 +279,31 @@ class Schema {
262279
if (!syntaxChecked) {
263280
// Check for extra space.
264281
if (realPath.indexOf(' ') !== -1) {
265-
throw new SyntaxError(`path "${path}" is not valid`)
282+
throw new SyntaxError(`path "${p}" is not valid`)
266283
}
267284
// Check if key is not defined (ex: []).
268285
if (realPath.indexOf('[]') !== -1) {
269-
throw new SyntaxError(`missing array index or object attribute in "${path}"`)
286+
throw new SyntaxError(`missing array index or object attribute in "${p}"`)
270287
}
271288
// Check for missing object attribute.
272289
if (dotIndex + 1 === realPath.length) {
273-
throw new SyntaxError(`missing object attribute in "${path}"`)
290+
throw new SyntaxError(`missing object attribute in "${p}"`)
274291
}
275292

276293
const closingBrackets = realPath.split(']').length
277294
const openingBrackets = realPath.split('[').length
278295

279296
// Check for missing opening bracket.
280297
if (openingBrackets < closingBrackets) {
281-
throw new SyntaxError(`missing opening bracket "[" in "${path}"`)
298+
throw new SyntaxError(`missing opening bracket "[" in "${p}"`)
282299
}
283300
// Check for missing closing bracket.
284301
if (closingBrackets < openingBrackets) {
285-
throw new SyntaxError(`missing closing bracket "]" in "${path}"`)
302+
throw new SyntaxError(`missing closing bracket "]" in "${p}"`)
286303
}
287304
}
288305

289-
let name = realPath
306+
let name: keyof F = realPath
290307
let subPath
291308

292309
// Resolve dot "." path.
@@ -313,26 +330,29 @@ class Schema {
313330
}
314331

315332
if (typeof this.fields[name] === 'undefined') {
316-
throw new FieldResolutionError(path)
333+
throw new FieldResolutionError(p)
317334
}
318335

319-
let field: SchemaField<unknown> = this.fields[name]
336+
const field = this.fields[name]
320337

338+
// Get nested field
321339
if (typeof subPath === 'string' && subPath.length > 0) {
322340
const type = field.getType()
323341
const props = field.getProperties()
324342

325343
if (type instanceof Schema) {
326-
field = type.resolveField(subPath, true)
327-
} else if (typeof props.items !== 'undefined' &&
344+
return type.resolveField(subPath, true)
345+
}
346+
if (typeof props.items !== 'undefined' &&
328347
typeof props.items.type !== 'undefined' &&
329348
props.items.type instanceof Schema) {
330-
field = props.items.type.resolveField(subPath, true)
331-
} else {
332-
throw new FieldResolutionError(path)
349+
return props.items.type.resolveField(subPath, true)
333350
}
351+
} else if (name in this.fields) {
352+
// @ts-ignore fixme TS error
353+
return field
334354
}
335-
return field
355+
throw new FieldResolutionError(p)
336356
}
337357

338358
/**
@@ -420,15 +440,6 @@ class Schema {
420440
}
421441
return clone
422442
}
423-
424-
/**
425-
* Returns a sub schema without some fields.
426-
* @deprecated use `omit()` instead
427-
* @param fieldNames
428-
*/
429-
without (fieldNames: string[]): Schema {
430-
return this.omit(fieldNames)
431-
}
432443
}
433444

434445
export default Schema

0 commit comments

Comments
 (0)