22

In Typescript the following seems like it should accomplish the creation of the desired type:

interface RecordX extends Record<string, string[]> {
  id: string
}

but this complains about:

Property 'id' of type 'string' is not assignable to string index type 'string[]'. ts(2411)

How can one add a property of a different type to a Record<> utility type?

Details and General Case + Sample

Generally, how can one describe an object with heterogenous-value-type fixed properties but homogenous-value-type properties that are dynamically added.

S, for example given this object:

const a = {
   // some properties with known "hard-coded" types
   id: '123',
   count: 123,
   set: new Set<number>(),

   // and some dynamic properties
   dynamicItemList: ['X', 'Y']
   anotherDynamicallyAddedList: ['Y', 'Z']
} as ExtensibleRecord

So how can one define a type or interface ExtensibleRecord where:

  1. the types and property keys of id, count, and set are fixed as string, number and Set<number>
  2. the types of dynamicItemList and anotherDynamicallyAddedList and any other properties added to the object are string[]

I've tried many variants that I'd think might work, including:

type ExtensibleRecord = {
  id: string, count: number, set: Set<number>
} & Record<string, string[]>

type ExtensibleRecord = {
  id: string, count: number, set: Set<Number>
} & Omit<Record<string, string[]>, 'id'|'count'|'set'>

interface ExtensibleRecord = {
  id: string,
  count: number,
  set: Set<number>,
  [k: string]: string[]
}

but each seems to result in errors.

This feels like something common and obvious, but I can't find an example or reference.

playground

4
  • 4
    Record<string, string[]> means that the id property, if it exists, must be a string[]. You can't extend it with something that doesn't match that; you're trying to make an exception, not an extension, and TypeScript does not (yet) support that as a concrete type. You might want to expand on your use case if you'd like a suggestion for what to do instead. And please make sure your code is a minimal reproducible example; right now a name collision with Record is obscuring your issue. Commented Jul 25, 2019 at 18:48
  • @jcalz Added more description + MWE Commented Jul 28, 2019 at 18:09
  • 1
    The reason is because TypeScript tries to do a bit more when it comes to object literal. It does that to avoid obvious errors. So if you add the dynamic property after the object literal construction, it will work. Commented Jul 28, 2019 at 18:16
  • Noting that the satisfies keyword can help here. Commented Jan 16, 2024 at 12:43

3 Answers 3

20
+250

From the official docs, it's not possible to achieve what you want:

While string index signatures are a powerful way to describe the “dictionary” pattern, they also enforce that all properties match their return type. This is because a string index declares that obj.property is also available as obj["property"]. In the following example, name’s type does not match the string index’s type, and the type checker gives an error:

interface NumberDictionary {
    [index: string]: number;
    length: number;    // ok, length is a number
    name: string;      // error, the type of 'name' is not a subtype of the indexer
}

However, according to this site, excluding certain properties from the index signature is possible in one situation: when you want to model a declared variable. I'm copying the entire section from the site.

Sometimes you need to combine properties into the index signature. This is not advised, and you should use the Nested index signature pattern mentioned above. However, if you are modeling existing JavaScript you can get around it with an intersection type. The following shows an example of the error you will encounter without using an intersection:

type FieldState = {
  value: string
}

type FormState = {
  isValid: boolean  // Error: Does not conform to the index signature
  [fieldName: string]: FieldState
}

Here is the workaround using an intersection type:

type FieldState = {
  value: string
}

type FormState =
  { isValid: boolean }
  & { [fieldName: string]: FieldState }

Note that even though you can declare it to model existing JavaScript, you cannot create such an object using TypeScript:

type FieldState = {
  value: string
}

type FormState =
  { isValid: boolean }
  & { [fieldName: string]: FieldState }


// Use it for some JavaScript object you are gettting from somewhere 
declare const foo:FormState; 

const isValidBool = foo.isValid;
const somethingFieldState = foo['something'];

// Using it to create a TypeScript object will not work
const bar: FormState = { // Error `isValid` not assignable to `FieldState
  isValid: false
}

Finally, as a workaround, if it fits, you might create a nested interface (check the section "Design Pattern: Nested index signature" of site, in which the dynamic fields are in a property of the interface. For instance

interface RecordX {
  id: string,
  count: number,
  set: Set<number>,
  dynamicFields: {
    [k: string]: string[]
  }
}
Sign up to request clarification or add additional context in comments.

1 Comment

Super valuable answer but the links don't work. Would be cool if you could update them :)
11

If you are not using it for class, you can describe it using type:

type RecordWithID = Record<string, string[]> & {
  id: string
}

let x: RecordWithID = {} as any

x.id = 'abc'

x.abc = ['some-string']

http://www.typescriptlang.org/play/#code/C4TwDgpgBAShDGB7ATgEwOoEtgAsCSAIlALywIqoA8AzsMpgHYDmANFLfcwNoC6AfFABkUAN4BYAFBQomVAC52dRk0kBfSZIA2EYFAAeCuEjRZchEqNVQAhtRsMQGiXoB0siwHJrAI3genrj7wFlxe1KgAZh48TkA

1 Comment

— I've encountered some issues with this, so I've updated the question with more details, and (hopefully temporarily) unmarked it as the correct solution.
2

Pedro's answer is 100% correct and was the start of my journey, thank you!

I wanted to discover the simplest workaround for this, and found one solution: Object.assign(). It seems to be excepted from the type errors of constructing these objects manually or even when using the spread operator, perhaps as a TypeScript interop with a native JavaScript feature.

Wrapping Object.assign() in a factory function combined with a parallel type feels elegant to me, and perhaps it will be useful to someone else.

// FACTORY WITH TYPE

// Factory uses Object.assign(), which does not error, and returns intersection type
function RecordX(
  fixedProps: { id: string },
  dynamicProps?: Record<string, string[]>
) {
  return Object.assign({}, dynamicProps, fixedProps);
}

// Type name conveniently overlaps with factory and resolves to:
// 
// type RecordX = Record<string, string[]> & {
//   id: string;
// }
type RecordX = ReturnType<typeof RecordX>;

// USAGE

// Standard use
const model: RecordX = RecordX({ id: 'id-1' }, {
  foo: ['a'],
});

// Correct type: string
const id = model.id;

// Correct type: string[]
const otherProp = model.otherProp;

// Appropriately errors with "string is not assignable to string[]"
const model2: RecordX = RecordX({ id: 'id-2' }, {
  bar: 'b'
});

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.