TypeScript type that matches any object but not arrays

Viewed 3571

I'm trying to define a type that matches any object/dictionary but NOT arrays.

My first attempt didn't work since arrays are technically objects under the hood:

const a:{[k:string]: any} = []; // works fine

I also know that it's possible to create a generic "checker" like so:

type NoArray<T> = T extends any[] ? never : T;

But that's not what I'm looking for. I want a non-generic type that works like this:

const a: NoArrayType = {}; // works fine
const a: NoArrayType = []; // TypeError
5 Answers

Type problem is the any in your type declaration. any is usually something you want to avoid in most typescript applications.

An array is just an object that can be indexed with numeric keys and has some extra methods. In fact you can assign pretty much any non primitive value to that type.

const a: {[k:string]: any} = [1,2,3]; // works
const b: {[k:string]: any} = {a: 123}; // works
const c: {[k:string]: any} = () => { console.log(123) }; // works
const d: {[k:string]: any} = () => new AnyClass(); // works

Playground

This works for the same reason you can do the following, because any is the one case where typescript always lets you cast a value to.

const a: any = true
const b: any = {}
const c: any = new AnyClass()

Playground

So you have a few options.

  1. Constrain your type, so that you aren't casting to any. If you know what possible values are on those properties, declare them.
interface MyObjectType { [k: string]: number | string }
const a: MyObjectType = [] // fails
const b: MyObjectType = {} // works

Playground

Or perhaps this is JSON? If so, any isn't the right type since you know it can't have some things (like class instances or functions).

interface Json {
  [key: string]: string | number | boolean | Json | Json[]
}

const a: Json = [] // type error
const b: Json = {} // works

Playground

  1. Or use the unknown type instead of any, which requires that you check the type at runtime before using the values.
interface MyObjectType { [k: string]: unknown }

const a: MyObjectType = [] // type error
const b: MyObjectType = { prop: 123 } // works

// b.prop type here is: unknown
b.prop.toUpperCase() // type error

if (typeof b.prop === 'string') {
  // a.prop type here is: string
  console.log(b.prop.toUpperCase()) // works
}

Playground

Here's what I came up with, using a type intersection instead of an index signature:

/**
 * Constrains a type to something other than an array.
 */
export type NotArray = (object | string | bigint | number | boolean) & { length?: never; };

This allows more than what the original poster was looking for, but it can easily be adjusted:

/**
 * Constrains a type to an object other than an array.
 */
export type NonArrayObject = object & { length?: never; };

The advantage of not using an index signature is that you get an error if you access a property which doesn't exist:

function test(hello: NonArrayObject) {
    return hello.world; // error
}

The disadvantages of using … & { length?: never; } are that you can access the length property of NotArray, that you cannot use it for objects which happen to have a length property, and that you cannot use it for functions because they have a length property.

And if anyone is wondering, I use NotArray to define optional return values, where in most cases only the first return value is of interest:

export type OptionalReturnValues2<T1 extends NotArray, T2> = T1 | [T1 | undefined, T2];

export function normalizeReturnValues2<T1 extends NotArray, T2>(optionalReturnValues: OptionalReturnValues2<T1, T2>): [T1 | undefined, T2 | undefined] {
    if (Array.isArray(optionalReturnValues)) {
        return optionalReturnValues;
    } else {
        return [optionalReturnValues, undefined];
    }
}

export type OptionalReturnValues3<T1 extends NotArray, T2, T3> = T1 | [T1 | undefined, T2] | [T1 | undefined, T2 | undefined, T3];

export function normalizeReturnValues3<T1 extends NotArray, T2, T3>(optionalReturnValues: OptionalReturnValues3<T1, T2, T3>): [T1 | undefined, T2 | undefined, T3 | undefined] {
    if (Array.isArray(optionalReturnValues)) {
        return [optionalReturnValues[0], optionalReturnValues[1], optionalReturnValues[2]];
    } else {
        return [optionalReturnValues, undefined, undefined];
    }
}

This seems to do what you need:

type NonArrayObject = {
    [x: string]: any
    [y: number]: never
}

let p: NonArrayObject = {}             // fine
let q: NonArrayObject = { foo: "bar" } // fine
let r: NonArrayObject = []             // type error
let s: NonArrayObject = ["foo", 3]     // type error

Edit: The type error for empty arrays seems to be a local artefact. In the playground it only seems to prevent populated arrays. Maybe that helps a little :)

This is what I came up with, which works and has a somewhat helpful error message where others have a mostly unhelpful error message:

type NotArray = {
  length?: never
  [key: string]: any
} | string | bigint | number | boolean

const h: NotArray = [1] // errors
const h1: NotArray = ['1'] // errors
const h3: NotArray = [{h: 'h'}] // errors
const h4: NotArray = {h: ['h']}
const h5: NotArray = {h: 1}
const h2: NotArray = 1

type MapOnly = Record<keyof any, unknown>

Related