Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,57 @@ expectTypeOf<1 | null>().toBeNullable()
expectTypeOf<1 | undefined | null>().toBeNullable()
```

Use `.branded.inspect` to find badly-defined paths:

This finds `any` and `never` types deep within objects. This can be useful for debugging, or for validating large or complex types. If there are `any` or `never` types lurking deep within the type, your IDE will highlight the bad paths.

Note: this is a fairly heavy operation, so you might not want to actually commit the assertions to source control.

```typescript
const bad = (metadata: string) => ({
name: 'Bob',
dob: new Date('1970-01-01'),
meta: {
raw: metadata,
parsed: JSON.parse(metadata), // whoops, any!
},
exitCode: process.exit(), // whoops, never!
})

expectTypeOf(bad).returns.branded.inspect({
foundProps: {
'.meta.parsed': 'any',
'.exitCode': 'never',
},
})
```

You can use `.branded.inspect` to confirm there are no unexpected types:

```typescript
const good = (metadata: string) => ({
name: 'Bob',
dob: new Date('1970-01-01'),
meta: {
raw: metadata,
parsed: JSON.parse(metadata) as unknown, // here we just cast, but you should use zod/similar validation libraries
},
exitCode: 0,
})

expectTypeOf(good).returns.branded.inspect({
foundProps: {},
})

// You can also use it to search for other types. Valid options for `findType` are currently only `'never' | 'any' | 'unknown'`.

expectTypeOf(good).returns.branded.inspect<{findType: 'unknown'}>({
foundProps: {
'.meta.parsed': 'unknown',
},
})
```

More `.not` examples:

```typescript
Expand Down
202 changes: 159 additions & 43 deletions src/branding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,29 @@ import type {
OptionalKeys,
MutuallyExtends,
UnionToTuple,
IsTuple,
Not,
UnionToIntersection,
TupleToRecord,
IsRecord,
} from './utils'

export type DeepBrandOptions = {
nominalTypes: {}
}

export type DeepBrandOptionsDefaults = {
nominalTypes: {
Date: Date
}
}

export type NominalType<T, Options extends DeepBrandOptions> = Options['nominalTypes'] extends infer N
? {
[K in keyof N]: MutuallyExtends<N[K], T> extends true ? K : never
}[keyof N]
: never

/**
* Represents a deeply branded type.
*
Expand All @@ -26,60 +47,155 @@ import type {
* when you know you need it. If doing an equality check, it's almost always
* better to use {@linkcode StrictEqualUsingTSInternalIdenticalToOperator}.
*/
export type DeepBrand<T> =
export type DeepBrand<T, Options extends DeepBrandOptions> =
IsNever<T> extends true
? {type: 'never'}
: IsAny<T> extends true
? {type: 'any'}
: IsUnknown<T> extends true
? {type: 'unknown'}
: T extends string | number | boolean | symbol | bigint | null | undefined | void
? {
type: 'primitive'
value: T
}
: T extends new (...args: any[]) => any
: Not<IsNever<NominalType<T, Options>>> extends true
? {type: NominalType<T, Options>}
Comment on lines +57 to +58
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sure there's a better way to do this without having to explicitly check for never/user NominalType twice

: T extends string | number | boolean | symbol | bigint | null | undefined | void
? {
type: 'constructor'
params: ConstructorOverloadParameters<T>
instance: DeepBrand<InstanceType<Extract<T, new (...args: any) => any>>>
type: 'primitive'
value: T
}
: T extends (...args: infer P) => infer R // avoid functions with different params/return values matching
? NumOverloads<T> extends 1
? {
type: 'function'
params: DeepBrand<P>
return: DeepBrand<R>
this: DeepBrand<ThisParameterType<T>>
props: DeepBrand<Omit<T, keyof Function>>
}
: UnionToTuple<OverloadsInfoUnion<T>> extends infer OverloadsTuple
: T extends new (...args: any[]) => any
? {
type: 'constructor'
params: ConstructorOverloadParameters<T>
instance: DeepBrand<InstanceType<Extract<T, new (...args: any) => any>>, Options>
}
: T extends (...args: infer P) => infer R // avoid functions with different params/return values matching
? NumOverloads<T> extends 1
? {
type: 'overloads'
overloads: {
[K in keyof OverloadsTuple]: DeepBrand<OverloadsTuple[K]>
}
type: 'function'
params: DeepBrand<P, Options>
return: DeepBrand<R, Options>
this: DeepBrand<ThisParameterType<T>, Options>
props: DeepBrand<Omit<T, keyof Function>, Options>
}
: never
: T extends any[]
? {
type: 'array'
items: {
[K in keyof T]: T[K]
}
}
: {
type: 'object'
properties: {
[K in keyof T]: DeepBrand<T[K]>
}
readonly: ReadonlyKeys<T>
required: RequiredKeys<T>
optional: OptionalKeys<T>
constructorParams: DeepBrand<ConstructorOverloadParameters<T>>
}
: UnionToTuple<OverloadsInfoUnion<T>> extends infer OverloadsTuple
? {
type: 'overloads'
overloads: {
[K in keyof OverloadsTuple]: DeepBrand<OverloadsTuple[K], Options>
}
}
: never
: T extends any[]
? IsTuple<T> extends true
? {
type: 'tuple'
items: {
[K in keyof T]: DeepBrand<T[K], Options>
}
}
: {
type: 'array'
items: DeepBrand<T[number], Options>
}
: IsRecord<T> extends true
? {
type: 'record'
keys: keyof T
values: DeepBrand<T[keyof T], Options>
}
: {
type: 'object'
properties: {
[K in keyof T]: DeepBrand<T[K], Options>
}
readonly: ReadonlyKeys<T>
required: RequiredKeys<T>
optional: OptionalKeys<T>
constructorParams: ConstructorOverloadParameters<T> extends infer P
? IsNever<P> extends true
? never
: DeepBrand<P, Options>
: never
}

/**
* Checks if two types are strictly equal using branding.
*/
export type StrictEqualUsingBranding<Left, Right> = MutuallyExtends<DeepBrand<Left>, DeepBrand<Right>>
export type StrictEqualUsingBranding<Left, Right, Options extends DeepBrandOptions> = MutuallyExtends<
DeepBrand<Left, Options>,
DeepBrand<Right, Options>
>

/**
* @internal don't use this unless you are deeply familiar with it!
*
* Walks over a type `T`, assuming that it's the output of the {@linkcode DeepBrand} utility. It looks for leaf nodes looking like `{type: FindType}`.
* When it finds them, it merges them into a string->string record, keeping track of a rough representation of the path-location.
* For simple objects, this path will roughly match dot-prop notation but it also traverses into all the structures that `DeepBrand` can emit.
* But it also goes into overloads, function parameters, return types, etc. The output is an ugly intersection of objects along with a marker `{deepBrandLeafNode: true}`
* which is purely for internal use. The output should not be shown to end-users!
*/
type _DeepPropTypesOfBranded<T, PathTo extends string, FindType extends string> =
IsNever<T> extends true
? {}
: T extends string
? {}
: T extends {type: FindType}
? {[K in PathTo]: T['type']} & {deepBrandLeafNode: true} // deepBrandLeafNode marker helps us throw out lots of array props which we don't want to include
: T extends any[]
? _DeepPropTypesOfBranded<TupleToRecord<T>, PathTo, FindType>
: UnionToIntersection<
{
[K in keyof T]: Extract<
_DeepPropTypesOfBranded<T[K], `${PathTo}${DeepBrandPropPathSuffix<T, Prop<K>>}`, FindType>,
{deepBrandLeafNode: true}
>
}[keyof T]
>

/** Required options for for {@linkcode DeepBrandPropNotes}. */
export type DeepBrandPropNotesOptions = Partial<DeepBrandOptions> & {
findType: 'any' | 'never' | 'unknown'
}
/** Default options for for {@linkcode DeepBrandPropNotes}. */
export type DeepBrandPropNotesOptionsDefaults = {findType: 'any' | 'never'}

/**
* For an input type `T`, finds all deeply-nested properties in the {@linkcode DeepBrand} representation of it.
*
* The output is a developer-readable shallow record of prop-path -> resolved type.
* @example
* ```ts
* type X = {a: any; b: boolean; c: {d: any}}
* const notes: DeepBrandPropNotes<X, {findType: 'any'}> = {
* '.a': 'any',
* '.c.d': 'any',
* }
* ```
*/
export type DeepBrandPropNotes<T, Options extends DeepBrandPropNotesOptions> =
_DeepPropTypesOfBranded<DeepBrand<T, DeepBrandOptionsDefaults & Options>, '', Options['findType']> extends infer X
? {} extends X
? Record<string | number | symbol, 'No flagged props found!'> // avoid letting `{'.propThatUsedToBeAny': 'any'}` still being accepted after it's fixed
: {[K in Exclude<keyof X, 'deepBrandLeafNode'>]: X[K]}
: never

/**
* @internal
*
* Helper to coerce a type `K` that you are already pretty sure is a string because it camed from a `keyof T` type expression.
* Useful because sometimes TypeScript forgets that.
* When it's not a string or number, it will output a big ugly literal type `'UNEXPECTED_NON_LITERAL_PROP'` - try to avoid this!
*/
export type Prop<K> = K extends string | number ? K : 'UNEXPECTED_NON_LITERAL_PROP'

/**
* Gets a sensible suffix to a property path for a {@linkcode DeepBrand} output type.
* `[number]` for arrays, empty string for objects, and parenthesised-input for anything else.
*/
type DeepBrandPropPathSuffix<T, K> = T extends {type: string}
? K extends 'items'
? '[number]'
: K extends 'properties'
? ''
: `(${Prop<K>})`
: `.${Prop<K>}`
Loading