Michael Zlatkovsky
Michael Zlatkovsky

Reputation: 8670

Forcing excess-property checking on variable passed to TypeScript function

Is there a way to force excess-property checking to happen, not just for an inlined object literal but one that derives from a variable?

For example, suppose I have an interface and a function

interface Animal {
    speciesName: string
    legCount: number,
}

function serializeBasicAnimalData(a: Animal) {
    // something
}


If I call 

serializeBasicAnimalData({
    legCount: 65,
    speciesName: "weird 65-legged animal",
    specialPowers: "Devours plastic"
})

I would get an error -- which for my situation, is what I want.  I only want the function to take in a generic animal description without extra specifics.


On the other hand, if I first create a variable for it, I don't get the error:

var weirdAnimal = {
    legCount: 65,
    speciesName: "weird 65-legged animal",
    specialPowers: "Devours plastic"
};
serializeBasicAnimalData(weirdAnimal);


So my question is: Is there a way to somehow force TypeScript to apply "excess property checking" to the function parameter irrespective of whether it's an inlined object or an object that has previously been assigned to a variable?

 

Upvotes: 31

Views: 5569

Answers (4)

Chris Hamilton
Chris Hamilton

Reputation: 10954

I had a similar issue while using Prisma, as it receives object literals and infers the return type based on their shape. However, passing excess properties in these objects causes runtime errors. I'm passing the same large object to multiple queries, so maintenance would be a nightmare if I had to write the whole object literal in every query.

I needed to pass an object around that typescript knows the exact shape of, but wanted to check it for excess properties against another type. I modified the accepted answer for this use case.

This is the (truncated) type generated from Prisma, a config object that tells it what data to return. Typescript infers the return type based on what values are passed in, so I can't just declare the object as this type.

  export type memberInclude = {
    account?: boolean | accountArgs
    faculty?: boolean | facultyArgs
    member_type?: boolean | member_typeArgs
    has_keyword?: boolean | has_keywordFindManyArgs
    insight?: boolean | insightFindManyArgs
    ...
  }

Here's a truncated example of the config object for the query

type CheckKeysAreValid<T, ValidProps> = Exclude<keyof T, keyof ValidProps> extends never
  ? T
  : "Invalid keys" | Exclude<keyof T, keyof ValidProps>;

const _includeMemberInfo = {
  account: true,
  member_type: true,
} as const;

// This checks for excess properties before exporting, 
// then assigns the original object type if successful
export const includeMemberInfo: CheckKeysAreValid<
  typeof _includeMemberInfo,
  Prisma.memberInclude
> = _includeMemberInfo;

So let's say the table name member_type gets changed to something else in the Prisma schema, it will get caught here at compile time. Normally it wouldn't because the account property makes the object an acceptable subset, so Prisma would error at runtime.

Then when I use the object in a database query, typescript knows the exact object shape, meaning it can infer the return type from Prisma.

  const member = await db.member.findUnique({
    where: { id },
    include: includeMemberInfo,
  });
  
  // Return type is member & {account: account} & {member_type: member_type}

Upvotes: 2

Ayman Morsy
Ayman Morsy

Reputation: 1419

I know this confusing and unexpected behavior

but I noticed that I can solve this by adding type annotation directly to the variable

-----------------👇
var weirdAnimal:Animal  = {
    legCount: 65,
    speciesName: "weird 65-legged animal",
    specialPowers: "Devours plastic"
};
serializeBasicAnimalData(weirdAnimal);

Upvotes: 1

dugong
dugong

Reputation: 4431

I needed this to enforce an object shape in Redux.

I have combined the answer in this great article and Shannon's answer in this thread here. I think this gives a tiny bit more concise way to implement this:

export type StrictPropertyCheck<T, TExpected, TError> = T extends TExpected
  ? Exclude<keyof T, keyof TExpected> extends never
    ? T
    : TError
  : TExpected

Here ^^ I put the T extends TExpected in the StrictPropertyCheck, that is all the difference actually but I thought linking the article above would help others landing on this thread.

Usage for redux action creator:

export type Credentials = {
  email: string
  password: string
}

export type StrictCreds<T> = T &
  StrictPropertyCheck<
    T,
    Credentials,
    'ERROR: THERE ARE EXCESS PROPERTIES IN CREDENTIALS OBJECT'
  >

export type AuthActionType =
  | {
      type: AuthAction.LOGIN
      payload: StrictCreds<Credentials>
    }
  | { type: AuthAction.LOGOUT 
export const login = <T>(credentials: StrictCreds<T>): AuthActionType => {
  return {
    type: AuthAction.LOGIN,
    payload: credentials as Credentials,
  }
}

Upvotes: 2

Shanon Jackson
Shanon Jackson

Reputation: 6541

Hope this helps, this will cause it to fail. The underlying cause here is Typescripts reliance on structural typing which is alot better than the alternative which is Nominal typing but still has its problems.

type StrictPropertyCheck<T, TExpected, TError> = Exclude<keyof T, keyof TExpected> extends never ? {} : TError;

interface Animal {
    speciesName: string
    legCount: number,
}

function serializeBasicAnimalData<T extends Animal>(a: T & StrictPropertyCheck<T, Animal, "Only allowed properties of Animal">) {
    // something
}

var weirdAnimal = {
    legCount: 65,
    speciesName: "weird 65-legged animal",
    specialPowers: "Devours plastic"
};
serializeBasicAnimalData(weirdAnimal); // now correctly fails

Upvotes: 36

Related Questions