Skip to content

Commit 222408a

Browse files
committed
👍 Allow isObjectOf to specify an optional property
1 parent 4423c5e commit 222408a

File tree

2 files changed

+132
-27
lines changed

2 files changed

+132
-27
lines changed

is.ts

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -146,12 +146,24 @@ type FlatType<T> = T extends RecordOf<unknown>
146146
? { [K in keyof T]: FlatType<T[K]> }
147147
: T;
148148

149-
export type ObjectOf<
150-
T extends RecordOf<Predicate<unknown>>,
151-
> = FlatType<{ [K in keyof T]: T[K] extends Predicate<infer U> ? U : never }>;
149+
type OptionalPredicateKeys<T extends RecordOf<unknown>> = {
150+
[K in keyof T]: T[K] extends OptionalPredicate<unknown> ? K : never;
151+
}[keyof T];
152+
153+
export type ObjectOf<T extends RecordOf<Predicate<unknown>>> = FlatType<
154+
& {
155+
[K in Exclude<keyof T, OptionalPredicateKeys<T>>]: T[K] extends
156+
Predicate<infer U> ? U : never;
157+
}
158+
& {
159+
[K in OptionalPredicateKeys<T>]?: T[K] extends Predicate<infer U> ? U
160+
: never;
161+
}
162+
>;
152163

153164
/**
154165
* Return a type predicate function that returns `true` if the type of `x` is `ObjectOf<T>`.
166+
* If `is.OptionalOf()` is specified in the predicate function, the property becomes optional.
155167
* When `options.strict` is `true`, the number of keys of `x` must be equal to the number of keys of `predObj`.
156168
* Otherwise, the number of keys of `x` must be greater than or equal to the number of keys of `predObj`.
157169
*
@@ -161,12 +173,12 @@ export type ObjectOf<
161173
* const predObj = {
162174
* a: is.Number,
163175
* b: is.String,
164-
* c: is.Boolean,
176+
* c: is.OptionalOf(is.Boolean),
165177
* };
166-
* const a: unknown = { a: 0, b: "a", c: true };
178+
* const a: unknown = { a: 0, b: "a" };
167179
* if (is.ObjectOf(predObj)(a)) {
168-
* // a is narrowed to { a: number, b: string, c: boolean }
169-
* const _: { a: number, b: string, c: boolean } = a;
180+
* // a is narrowed to { a: number, b: string, c?: boolean }
181+
* const _: { a: number, b: string, c?: boolean } = a;
170182
* }
171183
* ```
172184
*/
@@ -176,22 +188,16 @@ export function isObjectOf<
176188
predObj: T,
177189
options: { strict?: boolean } = {},
178190
): Predicate<ObjectOf<T>> {
179-
return (x: unknown): x is ObjectOf<T> => {
180-
if (!isRecord(x)) {
181-
return false;
182-
}
183-
const preds = Object.entries<Predicate<unknown>>(predObj);
184-
if (options.strict) {
185-
if (Object.keys(x).length !== preds.length) {
186-
return false;
187-
}
188-
} else {
189-
if (Object.keys(x).length < preds.length) {
190-
return false;
191-
}
192-
}
193-
return preds.every(([k, p]) => p(x[k]));
194-
};
191+
const preds = Object.entries(predObj);
192+
const allKeys = new Set(preds.map(([key]) => key));
193+
const requiredKeys = preds
194+
.filter(([_, pred]) => !(pred as OptionalPredicate<unknown>).optional)
195+
.map(([key]) => key);
196+
const hasKeys = options.strict
197+
? (props: string[]) => props.every((p) => allKeys.has(p))
198+
: (props: string[]) => requiredKeys.every((k) => props.includes(k));
199+
return (x: unknown): x is ObjectOf<T> =>
200+
isRecord(x) && hasKeys(Object.keys(x)) && preds.every(([k, p]) => p(x[k]));
195201
}
196202

197203
/**
@@ -273,7 +279,9 @@ export function isOneOf<T extends readonly Predicate<unknown>[]>(
273279
return (x: unknown): x is OneOf<T> => preds.some((pred) => pred(x));
274280
}
275281

276-
export type OptionalPredicate<T> = Predicate<T | undefined>;
282+
export type OptionalPredicate<T> = Predicate<T | undefined> & {
283+
optional: true;
284+
};
277285

278286
/**
279287
* Return a type predicate function that returns `true` if the type of `x` is `T` or `undefined`.
@@ -291,7 +299,9 @@ export type OptionalPredicate<T> = Predicate<T | undefined>;
291299
export function isOptionalOf<T>(
292300
pred: Predicate<T>,
293301
): OptionalPredicate<T> {
294-
return isOneOf([isUndefined, pred]);
302+
return Object.assign(isOneOf([isUndefined, pred]), {
303+
optional: true as const,
304+
});
295305
}
296306

297307
export default {

is_test.ts

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,9 +235,15 @@ Deno.test("isObjectOf<T>", async (t) => {
235235
c: isBoolean,
236236
};
237237
assertEquals(isObjectOf(predObj)({ a: 0, b: "a", c: true }), true);
238+
assertEquals(
239+
isObjectOf(predObj, { strict: true })({ a: 0, b: "a", c: true }),
240+
true,
241+
"Specify `{ strict: true }`",
242+
);
238243
assertEquals(
239244
isObjectOf(predObj)({ a: 0, b: "a", c: true, d: "ignored" }),
240245
true,
246+
"Object have an unknown property",
241247
);
242248
});
243249
await t.step("returns false on non T object", () => {
@@ -246,8 +252,17 @@ Deno.test("isObjectOf<T>", async (t) => {
246252
b: isString,
247253
c: isBoolean,
248254
};
249-
assertEquals(isObjectOf(predObj)({ a: 0, b: "a", c: "" }), false);
250-
assertEquals(isObjectOf(predObj)({ a: 0, b: "a" }), false);
255+
assertEquals(isObjectOf(predObj)("a"), false, "Value is not an object");
256+
assertEquals(
257+
isObjectOf(predObj)({ a: 0, b: "a", c: "" }),
258+
false,
259+
"Object have a different type property",
260+
);
261+
assertEquals(
262+
isObjectOf(predObj)({ a: 0, b: "a" }),
263+
false,
264+
"Object does not have one property",
265+
);
251266
assertEquals(
252267
isObjectOf(predObj, { strict: true })({
253268
a: 0,
@@ -256,13 +271,93 @@ Deno.test("isObjectOf<T>", async (t) => {
256271
d: "invalid",
257272
}),
258273
false,
274+
"Specify `{ strict: true }` and object have an unknown property",
259275
);
260276
});
261277
await testWithExamples(
262278
t,
263279
isObjectOf({ a: (_: unknown): _ is unknown => true }),
264280
{ excludeExamples: ["record"] },
265281
);
282+
await t.step("with optional properties", async (t) => {
283+
await t.step("returns proper type predicate", () => {
284+
const predObj = {
285+
a: isNumber,
286+
b: isOneOf([isString, isUndefined]),
287+
c: isOptionalOf(isBoolean),
288+
};
289+
const a: unknown = { a: 0, b: "a" };
290+
if (isObjectOf(predObj)(a)) {
291+
type _ = AssertTrue<
292+
IsExact<typeof a, { a: number; b: string | undefined; c?: boolean }>
293+
>;
294+
}
295+
});
296+
await t.step("returns true on T object", () => {
297+
const predObj = {
298+
a: isNumber,
299+
b: isOneOf([isString, isUndefined]),
300+
c: isOptionalOf(isBoolean),
301+
};
302+
assertEquals(isObjectOf(predObj)({ a: 0, b: "a", c: true }), true);
303+
assertEquals(
304+
isObjectOf(predObj)({ a: 0, b: "a" }),
305+
true,
306+
"Object does not have an optional property",
307+
);
308+
assertEquals(
309+
isObjectOf(predObj)({ a: 0, b: "a", c: undefined }),
310+
true,
311+
"Object has `undefined` as value of optional property",
312+
);
313+
assertEquals(
314+
isObjectOf(predObj, { strict: true })({ a: 0, b: "a", c: true }),
315+
true,
316+
"Specify `{ strict: true }`",
317+
);
318+
assertEquals(
319+
isObjectOf(predObj, { strict: true })({ a: 0, b: "a" }),
320+
true,
321+
"Specify `{ strict: true }` and object does not have one optional property",
322+
);
323+
});
324+
await t.step("returns false on non T object", () => {
325+
const predObj = {
326+
a: isNumber,
327+
b: isOneOf([isString, isUndefined]),
328+
c: isOptionalOf(isBoolean),
329+
};
330+
assertEquals(
331+
isObjectOf(predObj)({ a: 0, b: "a", c: "" }),
332+
false,
333+
"Object have a different type property",
334+
);
335+
assertEquals(
336+
isObjectOf(predObj)({ a: 0, b: "a", c: null }),
337+
false,
338+
"Object has `null` as value of optional property",
339+
);
340+
assertEquals(
341+
isObjectOf(predObj, { strict: true })({
342+
a: 0,
343+
b: "a",
344+
c: true,
345+
d: "invalid",
346+
}),
347+
false,
348+
"Specify `{ strict: true }` and object have an unknown property",
349+
);
350+
assertEquals(
351+
isObjectOf(predObj, { strict: true })({
352+
a: 0,
353+
b: "a",
354+
d: "invalid",
355+
}),
356+
false,
357+
"Specify `{ strict: true }` and object have the same number of properties but an unknown property exists",
358+
);
359+
});
360+
});
266361
});
267362

268363
Deno.test("isFunction", async (t) => {

0 commit comments

Comments
 (0)