Skip to content

Type Annotation for JavaScript Objects

Derek Lewis edited this page Oct 22, 2022 · 5 revisions

TypeScript Type Annotation for JavaScript Objects

⚠︎ Anti-pattern:
Use of a banned type detected JS-0296

Description

As some standard built-in JavaScript objects are considered dangerous or harmful, some built-in types have aliases.
The practice of banning certain types is often a good idea to help improve consistency and safety.

Applies to: TypeScript
Reported by: TSLint

This rule bans specific types from being used. Does not ban the corresponding runtime objects from being used.
It takes a list of ["regex", "optional explanation here"], which bans types that match regex.

This rule includes a set of "best practices" intended to provide safety and standardization in our codebase:

  • Don't use the upper-case primitive types; lower-case types should be used for consistency:
    • Object -> object
    • Number -> number
    • String -> string
    • BigInt -> bigint
    • Symbol -> symbol
    • Boolean -> boolean

ⓧ Avoid the object type if able, as it's currently hard to use due to not being able to assert that keys exist.

Purposely select the lowercase object type instead of the capitalized Object type when:

  • The variable in question being annotated doesn't inherit from Object.prototype^1

ⓘ  Better still would be to define the object's shape, but if it can be anything
ⓘ  object is better than any

ⓘ  unknown is better than any
⚠︎ You can assign any values to unknown type variables, but you cannot use them before doing a type check or type assertion.


⚠︎ Don't use Object as a type. Maybe use {} instead? Object literal expressions should be populated, though.


⚠︎ Avoid the both Object and {} types, as they mean "any non-nullish value".
ⓘ  This is a point of confusion for many developers, who think it means "any object type".


  • Avoid the Function type, as it provides little safety for the following reasons:
    • It provides no type safety when calling the value, which means it's easy to provide the wrong arguments
    • It accepts class declarations, which will fail when called, as they are called without the new keyword

Examples

Bad Practice

// use of upper-case primitives
const str: String = "foo";
const bool: Boolean = true;
const num: Number = 1;
const symb: Symbol = Symbol("foo");

// use a proper function type
const func: Function = () => 1;

// use safer object types
const lowerObj: object = {};
const capitalObj1: Object = 1;
const capitalObj2: Object = { a: "string" };
const curly1: {} = 1;
const curly2: {} = { a: "string" };

Recommended

// use lower-case primitives for consistency
const str: string = "foo";
const bool: boolean = true;
const num: number = 1;
const symb: symbol = Symbol("foo");

// use a proper function type
const func: () => number = () => 1;

// use safer object types
const lowerObj: Record<string, unknown> = {};

const capitalObj1: number = 1;
const capitalObj2: { a: string } = { a: "string" };

const curly1: number = 1;
const curly2: Record<"a", string> = { a: "string" };

Improved Intersection Reduction, Union Compatibility, and Narrowing<a

href="#improved-intersection-reduction-union-compatibility-and-narrowing" class="linkicon" aria-labelledby="improved-intersection-reduction-union-compatibility-and-narrowing">

TypeScript 4.8 brings a series of correctness and consistency improvements under --strictNullChecks. These changes affect how intersection and union types work, and are leveraged in how TypeScript narrows types.

For example, unknown is close in spirit to the union type {} | null | undefined because it accepts null, undefined, and any other type. TypeScript now recognizes this, and allows assignments from unknown to {} | null | undefined.

function f(x: unknown, y: {} | null | undefined) {
    x = y; // always worked
    y = x; // used to error, now works
}

Another change is that {} intersected with any other object type simplifies right down to that object type. That meant that we were able to rewrite NonNullable to just use an intersection with {}, because {} & null and {} & undefined just get tossed away.

- type NonNullable<T> = T extends null | undefined ? never : T;
+ type NonNullable<T> = T & {};

This is an improvement because intersection types like this can be reduced and assigned to, while conditional types currently cannot. So NonNullable<NonNullable<T>> now simplifies at least to NonNullable<T>, whereas it didn’t before.

function foo<T>(x: NonNullable<T>, y: NonNullable<NonNullable<T>>) {
    x = y; // always worked
    y = x; // used to error, now works
}

These changes also allowed us to bring in sensible improvements in control flow analysis and type narrowing. For example, unknown is now narrowed just like {} | null | undefined in truthy branches.

function narrowUnknownishUnion(x: {} | null | undefined) {
    if (x) {
        x;  // {}
    }
    else {
        x;  // {} | null | undefined
    }
}

function narrowUnknown(x: unknown) {
    if (x) {
        x;  // used to be 'unknown', now '{}'
    }
    else {
        x;  // unknown
    }
}

Generic values also get narrowed similarly. When checking that a value isn’t null or undefined, TypeScript now just intersects it with {} — which, again, is the same as saying it’s NonNullable. Putting many of the changes here together, we can now define the following function without any type assertions.

function throwIfNullable<T>(value: T): NonNullable<T> {
    if (value === undefined || value === null) {
        throw Error("Nullable value!");
    }

    // Used to fail because 'T' was not assignable to 'NonNullable<T>'.
    // Now narrows to 'T & {}' and succeeds because that's just 'NonNullable<T>'.
    return value;
}

value now gets narrowed to T & {}, and is now identical with NonNullable<T> – so the body of the function just works with no TypeScript-specific syntax.

On their own, these changes may appear small — but they represent fixes for many, many paper cuts that have been reported over several years.

For more specifics on these improvements, you can read more here.