- Published on
Getting and Setting Managed Metadata Fields via PnPjs
Managed metadata fields are one of the most misunderstood parts of the SharePoint REST API. The data comes back in an unexpected shape, saving requires a special wire format, and the documentation gives you just enough information to get it wrong.
This article is a focused reference: how to read managed metadata field values from list items, how to write them back correctly, and how to handle multi-value taxonomy fields β all via PnPjs v3.
πΊοΈ How Managed Metadata Fields Are Stored
A managed metadata (taxonomy) field stores two things per item:
- A hidden note field (
fieldname_0) that holds the full term value in wire format - A lookup field (
fieldname) that holds a WssId (a site-local integer) and the term label
When you read a list item, the managed metadata column comes back as an object:
{
"Department": {
"Label": "Engineering",
"TermGuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"WssId": 4
}
}
For multi-value taxonomy fields, the value is an array of these objects.
π Reading a Single Managed Metadata Field
import { SPFI } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
export interface IProjectItem {
Id: number;
Title: string;
DepartmentLabel: string;
DepartmentTermGuid: string;
}
export async function getProjectItems(sp: SPFI): Promise<IProjectItem[]> {
const items = await sp.web.lists
.getByTitle('Projects')
.items
.select('Id', 'Title', 'Department')
.expand('Department') // Required to get the full taxonomy object
.top(50)();
return items.map(item => ({
Id: item.Id,
Title: item.Title,
DepartmentLabel: item.Department?.Label ?? '',
DepartmentTermGuid: item.Department?.TermGuid ?? ''
}));
}
Always use .expand('FieldName') when selecting a managed metadata field β without it, SharePoint returns only the WssId integer, not the label and GUID.
π Reading a Multi-Value Managed Metadata Field
Multi-value taxonomy fields return an array. The structure is the same, but wrapped in a results property when using the legacy REST endpoint:
export interface ITaggedDocument {
Id: number;
Title: string;
Tags: Array<{ Label: string; TermGuid: string }>;
}
export async function getTaggedDocuments(sp: SPFI): Promise<ITaggedDocument[]> {
const items = await sp.web.lists
.getByTitle('Documents')
.items
.select('Id', 'Title', 'Tags')
.expand('Tags')
.top(100)();
return items.map(item => {
// PnPjs v3 normalises the response β multi-value fields come as an array
const tags = Array.isArray(item.Tags)
? item.Tags
: item.Tags?.results ?? [];
return {
Id: item.Id,
Title: item.Title,
Tags: tags.map((t: any) => ({
Label: t.Label,
TermGuid: t.TermGuid
}))
};
});
}
βοΈ Writing a Single Managed Metadata Field
The safest way to write a managed metadata field is via validateUpdateListItem. This method accepts the wire format string and handles WssId resolution server-side β you never need to look up or manage WssIds yourself.
Wire format: -1;#<term label>|<term GUID>
export async function setDepartment(
sp: SPFI,
itemId: number,
termLabel: string,
termGuid: string
): Promise<void> {
// The -1 WssId tells SharePoint to resolve the real WssId from the term GUID
const wireValue = `-1;#${termLabel}|${termGuid}`;
await sp.web.lists
.getByTitle('Projects')
.items
.getById(itemId)
.validateUpdateListItem([
{
FieldName: 'Department', // Internal field name (not display name)
FieldValue: wireValue
}
]);
}
Do not use .update() for managed metadata fields. The standard item update endpoint does not handle WssId resolution β you end up with a -1;# value permanently saved, which breaks list view rendering and search.
βοΈ Writing a Multi-Value Managed Metadata Field
For multi-value fields, concatenate multiple wire values separated by ;#:
export async function setDocumentTags(
sp: SPFI,
itemId: number,
tags: Array<{ label: string; guid: string }>
): Promise<void> {
// Multi-value format: -1;#Label1|GUID1;#-1;#Label2|GUID2
const wireValue = tags
.map(t => `-1;#${t.label}|${t.guid}`)
.join(';#');
await sp.web.lists
.getByTitle('Documents')
.items
.getById(itemId)
.validateUpdateListItem([
{
FieldName: 'Tags',
FieldValue: wireValue
}
]);
}
To clear all tags from a multi-value field, pass an empty string:
await sp.web.lists
.getByTitle('Documents')
.items
.getById(itemId)
.validateUpdateListItem([
{ FieldName: 'Tags', FieldValue: '' }
]);
βοΈ Adding a New Item with a Managed Metadata Field
When creating a new item with a managed metadata field, use validateUpdateListItem after the item is created β not as part of the initial add() call:
export async function createProjectWithDepartment(
sp: SPFI,
title: string,
termLabel: string,
termGuid: string
): Promise<void> {
// Step 1: Create the item (without the taxonomy field)
const result = await sp.web.lists
.getByTitle('Projects')
.items
.add({ Title: title });
// Step 2: Set the managed metadata field on the new item
const newItemId = result.data.Id;
await sp.web.lists
.getByTitle('Projects')
.items
.getById(newItemId)
.validateUpdateListItem([
{
FieldName: 'Department',
FieldValue: `-1;#${termLabel}|${termGuid}`
}
]);
}
π Getting the Internal Field Name
The internal name of a managed metadata field is often different from its display name. To find it:
// List all fields and find your taxonomy field
const fields = await sp.web.lists
.getByTitle('Projects')
.fields
.filter(`TypeAsString eq 'TaxonomyFieldType' or TypeAsString eq 'TaxonomyFieldTypeMulti'`)
.select('InternalName', 'Title', 'TypeAsString')();
fields.forEach(f => console.log(f.InternalName, f.Title, f.TypeAsString));
Use the InternalName value in all validateUpdateListItem calls and $select parameters. Display names with spaces cause unpredictable results.
β Summary
- Read managed metadata fields with
.select('FieldName').expand('FieldName')β withoutexpand, you get only the WssId integer. - Multi-value taxonomy fields return an array β normalise with
Array.isArray(item.Field) ? item.Field : item.Field?.results ?? []. - Always use
validateUpdateListItemfor writes β it handles WssId resolution and is the only safe method for taxonomy fields. - Wire format for a single value:
-1;#<label>|<termGuid>. For multiple values: join with;#. - When creating a new item with a taxonomy field, create the item first, then set the taxonomy field in a separate
validateUpdateListItemcall. - Use the field's
InternalName(not display name) in all API calls.
Happy coding!
Author
Ravichandran@Hi_Ravichandran
